1. Why replace null if we have null-safety
If you write programs long enough, you notice one tricky thing: null is not only “there is no value”. In practice, null often starts to mean “something went wrong, but I’m too lazy to explain”. And this is where the magic begins: the calling code receives T? and now must guess what it was — “not found”, “not provided”, “parse error”, “access denied”, or “the developer forgot to implement it”. Kotlin protects us from NPE, but it cannot protect us from foggy contracts.
Imagine you have a function findExpenseById(id): Expense?. This is a good and honest contract: an expense with such an id may not exist. But if you have a function saveExpense(expense): Expense?, and it returns null for any problem, that’s already a “black box”: why null? what should we do next? retry? show a message? crash? And what’s especially unpleasant — you won’t know this from the signature.
In this lecture we cover three practical replacements for null, and each of them answers a specific question:
- preconditions (require, check, requireNotNull, checkNotNull) answer the question “can this even be called like this?”;
- sealed results answer the question “what meaningful outcomes can this operation have?”;
- Result<T> answers the question “how do we return success/error as data, without turning everything into exceptions and without losing the cause?”.
2. Preconditions: require, check, and *NotNull
Preconditions are not “user input validation”; they protect your function’s contract. It’s a way to say: “If bad data got here, this is not a ‘real-life scenario’; it’s an API misuse error (or a bug in the code above). I’d rather fail immediately with a clear message than drag garbage further and break 20 lines later in a weird place.”
That’s exactly why preconditions most often live inside domain logic, where “there must not be bad data anymore”, and at the boundary between layers, where you want to sharply separate the “raw world” from the “strict world”.
require(...): validate arguments
A small “meaning cheat sheet”: require means “you called the function incorrectly”. It throws IllegalArgumentException.
Let’s take our training CLI project (the course’s practical project) — an expense tracker. We have a model:
data class Expense(
val id: Int,
val title: String,
val amountCents: Int
)
Suppose a domain function creates an expense. We want to guarantee you can’t create an expense with an empty title and a negative amount:
fun createExpense(id: Int, title: String, amountCents: Int): Expense {
require(id > 0) { "id must be positive" }
require(title.isNotBlank()) { "title must not be blank" }
require(amountCents > 0) { "amountCents must be positive" }
return Expense(id = id, title = title.trim(), amountCents = amountCents)
}
What matters here is that require is not “handle gently”. It’s “stop the program because someone clearly made a mistake”. If you’re thinking: “But you can’t crash because of user input!” — you’re right. But this is not about input. This is about the layer where input should already have been validated.
check(...): validate state and invariants
check means “my program ended up in an impossible state”. It throws IllegalStateException. In real life, check is often used when an object has already been created and should be correct, but you want to guard against a logical error.
For example, we have an expense repository that stores a list. We expect id values to be unique. If we find two expenses with the same id, that’s not “user error”; that’s an error in our logic:
fun findUniqueById(items: List<Expense>, id: Int): Expense? {
val matches = items.filter { it.id == id }
check(matches.size <= 1) { "BUG: duplicate id=$id found (${matches.size} items)" }
return matches.firstOrNull()
}
Yes, it still returns Expense?, because “not found” is a normal situation. But at the same time, we protect the invariant “there must be no duplicates”.
requireNotNull and checkNotNull: converting T? → T without !!
A very common pain point: you got a String? (for example, from parsing or from Map.get()), but at this point it must be non-null. Instead of !!, it’s better to use requireNotNull/checkNotNull — because they not only turn T? into T, but also let you write a human-readable message.
fun normalizeCommand(raw: String?): String {
val text = requireNotNull(raw) { "Command line must not be null" }
return text.trim()
}
Unlike raw!!, here if it crashes you’ll see a meaningful reason, not “NullPointerException somewhere over there”.
Elvis and Nothing: “either a value, or we crash”
Another useful pattern is “Elvis + a function that always crashes”. This connects the topic of preconditions with the type Nothing: the expression throw ... has type Nothing, and therefore works great on the right side of Elvis. In Kotlin docs, this is shown with the example of fail(message): Nothing, which always throws an exception.
3. sealed results instead of null
Sometimes the problem is not that “there is no value”. The problem is that an operation has several outcomes, and each one is meaningful. If you encode everything via null, you lose meaning and force the caller to guess.
sealed class lets you make a contract like: “a result always exists, but it comes in different forms”. And Kotlin forces you to handle all cases (an exhaustive when) if you don’t leave yourself a loophole with else.
Delete by id: several normal outcomes
Deleting an expense is a great example: different outcomes are possible. For example:
- deleted successfully;
- no such id exists;
- the id is invalid (e.g., <= 0) — which is more of an API misuse error.
We’ll guard an invalid id with require, and represent “no such expense” as a result variant.
sealed class RemoveExpenseResult {
data object Removed : RemoveExpenseResult()
data object NotFound : RemoveExpenseResult()
}
fun removeExpenseById(items: MutableList<Expense>, id: Int): RemoveExpenseResult {
require(id > 0) { "id must be positive" }
val index = items.indexOfFirst { it.id == id }
if (index == -1) return RemoveExpenseResult.NotFound
items.removeAt(index)
return RemoveExpenseResult.Removed
}
Notice: there is no null here at all. It returns a concrete meaning.
Now the CLI layer can nicely form a message for the user:
fun describeRemove(result: RemoveExpenseResult, id: Int): String = when (result) {
RemoveExpenseResult.Removed -> "Removed expense id=$id"
RemoveExpenseResult.NotFound -> "No expense with id=$id"
}
The when is exhaustive because sealed closes the set of variants. If you add a new variant, the compiler itself will remind you where you forgot to handle it. It’s an “alarm clock for quality” that’s impossible to ignore (unlike a comment TODO handle errors later).
The Ok/Error pattern, but without T? inside Ok
Many people like the Ok/Error template. It’s convenient, but there’s a typical trap: making Ok(value: T?). Then you drag nullable back into “success”, and the whole idea is lost.
Let’s make the result of adding an expense to the repository. Suppose duplicate id values are forbidden.
sealed class AddExpenseResult {
data class Ok(val added: Expense) : AddExpenseResult()
data class Error(val message: String) : AddExpenseResult()
}
fun addExpense(items: MutableList<Expense>, expense: Expense): AddExpenseResult {
val exists = items.any { it.id == expense.id }
if (exists) return AddExpenseResult.Error("Expense with id=${expense.id} already exists")
items.add(expense)
return AddExpenseResult.Ok(added = expense)
}
Here Ok is guaranteed to contain a non-null Expense. And Error carries the error text as data (not as a println inside the function — we’ll talk about that in “common mistakes”).
Why sealed is better than null for “error/not found”
The most common reason to choose sealed instead of T? — is when you have two different meanings of absence:
- “not found” (normal);
- “error” (you need to report it, possibly with a cause).
If you make Expense?, you won’t be able to tell “not found” from “couldn’t because of a validation/logic error”. Yes, you could “print to the console” — but then the function becomes untestable and noisy. It’s better to return meaning in the types.
4. Result<T>: error as a value
Result<T> is Kotlin’s standard container: either success with a T, or failure with a Throwable. It’s handy where an error is naturally expressed as an exception (for example, parsing a number via toInt()), but you want to return it as data, without doing try/catch on every corner.
In essence, it’s a compromise: you don’t reject exceptions as a mechanism (they’re still inside), but you expose a neat “success/error” contract outward.
Parsing: Result<Int> instead of Int?
In the early days of the course, we often did toIntOrNull() and got Int?. That’s fine when you only need the fact “worked/didn’t work”. But sometimes you care about the reason: empty string? not a number? too large? negative? And you want to distinguish between them.
Result lets you keep the exception and add your own checks via mapCatching.
fun parsePositiveInt(raw: String): Result<Int> {
return runCatching { raw.trim().toInt() }
.mapCatching { value ->
require(value > 0) { "Expected a positive integer" }
value
}
}
There’s an important detail here: we use require inside mapCatching, and if the condition is not met, that also ends up as a Failure. This is convenient because the caller gets a single format to handle.
Result in the CLI layer of the expense tracker
Imagine the command looks like: add <id> <title> <amountCents>. We read a line, split it, attempt to parse it.
fun parseAddArgs(parts: List<String>): Result<Triple<Int, String, Int>> {
return runCatching {
val id = parts[1].toInt()
val title = parts[2]
val amount = parts[3].toInt()
Triple(id, title, amount)
}
}
Yes, IndexOutOfBoundsException or NumberFormatException may be thrown, and they will be neatly captured inside Result.
Next, we convert this result into a clear message:
fun describeParse(result: Result<Triple<Int, String, Int>>): String {
return result.fold(
onSuccess = { "Parsed: id=${it.first}, title=${it.second}, amount=${it.third}" },
onFailure = { "Bad command arguments: ${it.message ?: "unknown error"}" }
)
}
This is an example where Result is useful exactly at the input boundary: we want not to crash, but also not to stay silent.
Why Result<T?> is almost always a bad idea
The combination Result<T?> means: “the operation might fail, and if it doesn’t fail, the value might still be absent”. This is rarely a good contract, because you create two independent axes of uncertainty: first figure out whether there was an exception, then figure out why null.
Usually it’s better to choose one thing:
- if “it might be missing” — use T? or sealed Found/Missing;
- if “it might break” — use Result<T> or sealed Ok/Error.
And only if you really need to distinguish “success, but empty” from “error” (for example, a cache lookup: there may be no entry, but that’s not an error) — then Result<T?> is sometimes justified. But that’s a decision that requires very clear documentation of the contract.
5. How to choose an approach
When you first start replacing null, a new danger appears: replacing it everywhere at once and making the code “too clever”. So it’s helpful to keep a simple selection scheme in mind: what exactly “no value” means in your spot, and who is at fault — the user or the programmer.
Below is a practical table (not “academic truth”, but a working guideline):
| Situation | Best tool | Why |
|---|---|---|
| Inside the code this value must exist (violation means a bug/incorrect call) | |
Fail-fast: crash immediately and at a clear point |
| The operation has several meaningful outcomes (found/not found, ok/error, access denied, conflict) | |
Types force you to handle all variants; meaning is preserved |
| An error is naturally expressed as an exception, but you want “error as data” | |
Convenient at boundaries; preserves |
Now an important architectural point: these approaches can be combined, but you should do it by layers.
In our expense tracker, it looks logical like this: parsing user input returns Result, because input is dirty and unpredictable. A domain operation returns a sealed result, because meaningful outcomes matter there (“duplicate id”). And preconditions protect invariants and function contracts where “this must not happen”.
Putting it all together into one scenario
Now let’s assemble the pieces into one small flow. Suppose we have a command to add an expense. We parse input via Result, create the model via require-based protection, and return a sealed result in the repository.
fun handleAddCommand(items: MutableList<Expense>, parts: List<String>): String {
val parsed = parseAddArgs(parts)
return parsed.fold(
onSuccess = { (id, title, amount) ->
val expense = createExpense(id, title, amount)
when (val addRes = addExpense(items, expense)) {
is AddExpenseResult.Ok -> "Added: ${addRes.added}"
is AddExpenseResult.Error -> "ERROR: ${addRes.message}"
}
},
onFailure = { "ERROR: bad arguments (${it::class.simpleName})" }
)
}
There’s a lot going on here even though the code is short: we don’t use null as a “universal answer”. Each layer speaks its own language:
- the parser says “success/exception” (Result);
- the domain factory says “either we create it correctly, or it’s a bug/contract violation” (require);
- the repository says “success or a business-rule error” (sealed Ok/Error).
And, importantly, the caller does not guess. It simply handles the variants.
6. Common mistakes
Mistake No. 1: using requireNotNull as “user input validation”.
When a user enters something incorrectly, that’s a normal real-life situation, not a reason to crash with IllegalArgumentException. Preconditions are appropriate where bad data means a bug or incorrect use of a function, not “the user mistyped a digit”. For input, prefer Result or sealed Error.
Mistake No. 2: making a sealed result, but hiding meaning back inside null.
A classic: sealed class Result { data class Ok(val value: T?) ... }. On paper you “moved away from null”, but in reality you just repackaged null into a box labeled “Ok”. If you have Ok, it should contain a proper value. If the value may be absent, that’s a separate variant (Missing, NotFound).
Mistake No. 3: returning null and printing the reason via println inside the function.
Such code is hard to test, hard to reuse, and impossible to handle properly at the UI/CLI level. A function should either return a clear result with a cause (text/error type), or crash (if it’s a precondition), but not “whisper to the console and disappear into the fog”.
Mistake No. 4: turning Result into an “error silencer”.
runCatching is easy to use like a rug that everything gets swept under. If you do runCatching { ... }.getOrNull() and silently ignore Failure, you’re back to null, only now you’ve also lost the cause. If you chose Result, respect it: handle onFailure, add context, and form messages.
Mistake No. 5: mixing styles without an agreement and getting “three levels of uncertainty”.
For example, a function returns Result<User?>, where null means “not found”, and Failure means “error”, and also inside User you have name: String? “just in case”. As a result, you’re forced to check everything three times. Usually it’s better to build one clear model: either sealed Found/Missing, or Result<User>, and inside the domain model — strict non-null fields.