1. Introduction
When we write a function, we usually think: “It takes parameters and returns a result.” But real life quickly steps in and brings gifts: “input is not a number,” “not found,” “operation is impossible,” “file didn’t open,” “state is broken.” And this is where the adult part of programming begins: you need to agree on how a function reports an error.
This agreement is called an error contract: which situations count as errors, which of them are expected, and in what form they are returned to the calling code.
The contract matters because without it, code starts living “by mood.” In one place you return null, in another you throw an exception, in a third you print println("Error") and move on as if nothing happened. This behavior is especially dangerous in large programs: the error either “hides,” or surfaces too late and in a strange place.
A useful thought worth capturing: an error is not “it just crashed”. An error is a situation where a function cannot fulfill the promise of its contract.
2. The main ways to signal an error
When you choose an error-handling strategy, you have two main tools: exceptions and error as a value.
Exception
An exception interrupts the normal flow of execution: “I can’t continue; let someone outside decide.” It’s a powerful mechanism, but it’s like a fire alarm: great when there’s actually a fire, but weird if you use it to report that you ran out of sugar.
“Error as a value”
“Error as a value” means the function returns not only “success,” but also “failure” as a normal return value. We already did this via a sealed class with variants Ok and Error: the calling code must look at what came back and handle it.
It helps to picture the difference like this:
flowchart TD
A[Function call] --> B{What happened?}
B -->|Success| C[Returned a value]
B -->|Expected problem| D[Returned Error as a value]
B -->|Accident or logical impossibility| E[Threw an exception]
And here’s the key: the choice between D and E is not “what’s easier to write,” but what the situation means.
3. Fail-fast checks: require(...) and check(...)
Kotlin provides very convenient tools for fail-fast behavior: require(...) and check(...). They look similar, but differ in meaning.
require(...): a problem with input data
A common situation: a function receives arguments, and you already know that some values are meaningless. For example, “percent of total” when total = 0, or “expense amount” less than zero. This is not a normal scenario branch; it’s an incorrect function call.
In these places, require(...) is ideal: it checks a precondition and, if it isn’t satisfied, throws an exception about an invalid argument.
Mini example:
fun percent(part: Int, total: Int): Int {
require(total > 0) { "total must be > 0, got $total" }
require(part >= 0) { "part must be >= 0, got $part" }
return part * 100 / total
}
fun main() {
println(percent(2, 5)) // 40
}
Notice the message style: we don’t write “oops,” not “error,” but formulate the expectation and the actual value: got .... This saves hours of debugging, especially when you forget you wrote this function and come back to it a month later (that is, tomorrow).
It’s important to understand the philosophy of require: this is not “handling an error,” it’s a ban on proceeding because the input does not satisfy the contract. That is, we don’t try to “somehow survive” invalid arguments—we honestly say: “you can’t do that.”
check(...): a problem with state
If require(...) is about “the calling code passed bad arguments,” then check(...) is about “inside the program, the state is such that this must not happen.” This is closer to a bug: the logic somewhere broke an invariant.
Kotlin explicitly separates these cases: check(...) throws an exception about an invalid state.
Let’s imagine a piece of our practical project (let’s call it ExpenseTracker), where we have a “current open session” (for example, an active budget month). If code tries to add an expense but an active period hasn’t been selected, it’s not “the user is to blame via the arguments of addExpense(...),” it’s that the application state is not ready.
class Session(var activeMonth: String?)
fun addExpense(session: Session, title: String) {
check(session.activeMonth != null) { "Active month is not selected" }
println("Added expense '$title' for month ${session.activeMonth}") // example
}
fun main() {
val s = Session(activeMonth = "2026-01")
addExpense(s, "Coffee") // Added expense 'Coffee' for month 2026-01
}
If activeMonth is not selected, continuing “as if everything is fine” is dangerous. It’s better to fail immediately and loudly so the developer (that is, you) sees the problem in tests/debugging, rather than getting a “quiet wrong report” in production.
4. Expected errors and the Ok/Error result
Sometimes the situation is unpleasant, but expected: the user entered a non-existent command, asked to delete an expense by an id that doesn’t exist, or requested a report for a category that doesn’t exist yet.
This doesn’t look like an “accident.” It looks like a “normal branch of application life”: users can make mistakes, data can be missing, and that’s not a reason to set off a fireworks show of exceptions.
In such cases, it’s more convenient to return a result as an explicit value, for example:
sealed class CmdResult<out T> {
data class Ok<T>(val value: T) : CmdResult<T>()
data class Error(val message: String) : CmdResult<Nothing>()
}
Why is this convenient?
Because the calling code sees the contract right in the type: the function can return an error, and you must handle it. It’s almost like a road sign that says “Caution: turn”: you can ignore it, but don’t be surprised later.
Mini example: “find an expense by id.” If we didn’t find it, that’s not an accident; it’s expected.
data class Expense(val id: Int, val title: String)
fun findExpense(expenses: List<Expense>, id: Int): CmdResult<Expense> {
val found = expenses.find { it.id == id }
?: return CmdResult.Error("Expense not found: id=$id")
return CmdResult.Ok(found)
}
fun main() {
val items = listOf(Expense(1, "Coffee"))
println(findExpense(items, 2)) // Error(message=Expense not found: id=2)
}
The important idea here: “not found” is not an exception, because the absence of an element is often part of the scenario. In a console app, you’ll typically turn Error into user-friendly text and continue the command loop.
Why null is not a universal “error”
It’s very tempting to do this: “if something goes wrong, I’ll return null.” It’s tempting because it’s quick and you don’t need to declare new types. But this approach has a cost: null says almost nothing about the reason.
null is good for the situation “there is no value” or “not found,” when the reason isn’t important or is obvious. For example, Map[key] returns V?, and that’s fine: “there may be no value for the key.”
But as soon as you need to distinguish reasons, null starts to hurt. Imagine two cases:
- the user entered an empty string
- the user entered "12a"
In both cases, toIntOrNull() will return null. But it’s useful for the user (and for you while debugging) to understand the difference.
A “soft, no-reason” contract (sometimes fine):
fun parseAmountOrNull(text: String): Int? = text.toIntOrNull()
fun main() {
println(parseAmountOrNull("12a")) // null
}
An “explicit error with a message” contract (often better for CLI):
fun parseAmount(text: String): CmdResult<Int> {
val trimmed = text.trim()
if (trimmed.isEmpty()) return CmdResult.Error("Amount is empty")
val n = trimmed.toIntOrNull() ?: return CmdResult.Error("Amount is not a number: '$text'")
if (n <= 0) return CmdResult.Error("Amount must be > 0, got $n")
return CmdResult.Ok(n)
}
Here we don’t use exceptions because the errors are expected: the user enters data by hand, and hands sometimes have a mind of their own.
5. Responsibility boundary: application layers
Now comes an important architectural point; without it, the topic of errors turns into a debate about taste. In a large project, the boundary between exceptions and “error as a value” often coincides with the boundary between layers.
In our course, we already mentally split the program into parts: CLI (input/output), domain (command logic), storage (persistence). And it makes sense to tie the form of errors to the location.
Look at the following table as a “team agreement” (even if the team is just you and your cat):
| Layer | What it does | Typical problems | How to express them most often |
|---|---|---|---|
| CLI | reads strings, prints responses | “the user entered nonsense” | error as a value (Ok/Error), sometimes null |
| Domain | domain rules (commands, checks) | “no such expense,” “cannot delete the last item” | error as a value (Ok/Error), require/check for internal guarantees |
| Storage | files, DB, network | “file didn’t open,” “no permissions,” “broken format” | often exceptions, because it’s I/O and environment failures |
Why do people often leave storage on exceptions? Because “the file didn’t open” isn’t always an expected branch of domain logic. It’s an environment failure. You still have to decide what to do (inform the user, terminate the program, suggest a path), but inside low-level code it’s simpler and more honest to throw an exception and handle it at the scenario boundary.
But in the domain layer it’s often more convenient to return Ok/Error, because these are the “rules of the game” of the application: the user can request deleting a non-existent id—that’s expected.
6. Practice: the remove command and its contract
Let’s assemble a small fragment that shows the contract in action. Imagine that the CLI layer has already parsed the id (as a number), and in the domain layer we perform deletion of an expense.
Scenario: if the id is not found, that’s not an exception, but an Error. If the list is empty and we try to delete, that’s also an Error. But if we’re given a negative id, that already looks like a bug in code higher up the stack (CLI should have validated it), and here we can use require.
data class Expense(val id: Int, val title: String)
fun removeExpense(expenses: MutableList<Expense>, id: Int): CmdResult<Expense> {
require(id > 0) { "id must be > 0, got $id" }
val index = expenses.indexOfFirst { it.id == id }
if (index == -1) return CmdResult.Error("No expense with id=$id")
val removed = expenses.removeAt(index)
return CmdResult.Ok(removed)
}
fun main() {
val expenses = mutableListOf(Expense(1, "Coffee"))
println(removeExpense(expenses, 2)) // Error(message=No expense with id=2)
}
What matters here.
An invalid argument (id <= 0) is considered a violation of the call contract and is expressed as fail-fast via require(...). And the expected situation “id not found” is returned as an Error, because it’s a normal scenario branch.
7. How to choose an approach: practical logic
In practice, it’s convenient to make the decision not by the principle “I like exceptions” or “I like sealed classes,” but by a few grounded questions.
- If the situation is expected and happens regularly (for example, the user entered an invalid command), then an exception turns into noise. You’ll have to write try/catch everywhere it’s used, and the code will start to look like a TV series called “Catch Me If You Can.” For expected branches, “error as a value” is almost always the better fit.
- If after the problem you can continue working (for example, show a message and ask for input again), then “error as a value” is more natural: you remain in the normal control flow, you just go down a different branch.
- But if the situation is an emergency or indicates a violation of program logic, then continuing is dangerous. Here an exception (or require/check/error) is justified: it forces us not to pretend everything is okay.
- Finally, if you’re at the boundary with the “environment” (files, network, permissions), then an exception is often more honest: it’s a failure that’s hard to model as a normal branch of domain logic. In such places, people usually catch the exception at the top scenario level and turn it into a user-friendly message.
8. Two extremes and why they are harmful
There are two extremes, and both are regularly seen in beginners.
The first extreme is “exceptions for everything.” Then every user sneeze turns into try/catch jungle. At some point you catch Exception (because otherwise it’s impossible to live), and it becomes a universal bag where both real emergencies and expected input errors fall. Debugging such code usually looks like an archaeological dig: you found a stack trace, but you’re not sure whether it’s a bug or the user pressed the wrong key.
The second extreme is “no exceptions at all, only Error(message).” This is also dangerous: you can accidentally sweep under the rug a situation where the program must not continue (for example, a data invariant is broken). Then instead of an honest crash you get a “quiet incorrect state,” and later reports will be wrong, data will be corrupted, and the user will start suspecting there’s a poltergeist living in your app.
A mature approach is mixed, but consistent: at one level you choose one policy and stick to it.
What’s next
Today’s lecture is about meaning and agreements. For now we deliberately work with simple tools: require/check and our own Ok/Error.
In the next lectures of the day, we’ll learn to consciously write try/catch as an expression (that is, “return a value from try or from catch”) and we’ll see more standard ways to package errors into a value. Kotlin really supports the style where try returns a value, and that’s convenient when you choose the policy “on error, return a default or a message.”
The foundation remains the same: first you decide what to consider an error and what it should look like to the calling code, and only then you pick syntax.
9. Common mistakes
Mistake #1: turning expected situations into exceptions.
If the user entered an unknown command, it’s not “the program broke,” it’s “the user asked for something that doesn’t exist.” When you model such situations with exceptions, the code quickly grows over with try/catch, and real emergencies get lost among “errors-as-branches.” The remedy is simple: everything expected in the scenario should be returned as Ok/Error and handled with a regular when.
Mistake #2: using null as a universal error.
null clearly says “there is no value,” but it barely says “why.” As a result, you’re forced either to guess the reason or print a generic “failed.” If the reason matters to the user (and in CLI it almost always does), it’s better to return Error("a clear message") than null.
Mistake #3: applying require/check with the wrong meaning.
Sometimes beginners use require(false) just “to crash.” Formally it works, but the meaning is lost. require should express a precondition on arguments, and check should express a state invariant. This makes code self-documenting: from one line it’s clear who is “responsible” for correctness (the calling code or the internal logic).
Mistake #4: “silencing” an error and pretending everything is normal.
The most insidious mistake is when the program continues after a problem even though it shouldn’t. For example, you “silently” ignore deletion of a non-existent id, and the user thinks the deletion succeeded. It’s better to return an Error with a message or terminate the scenario if continuing is meaningless.
Mistake #5: mixing different contracts in one function.
When the same function sometimes throws an exception, sometimes returns an Error, sometimes returns null, the calling code turns into a detective: “what am I supposed to check this time?” Try to ensure a function has one clear contract: either it signals problems with values (Ok/Error), or it fails with an exception (and this should be justified by meaning), or it returns T? in strictly limited cases like “not found” or “no value.”
GO TO FULL VERSION