sealed and when: Ok / Error style and exhaustive handling

Open

1. Introduction

Honestly, println() is very tempting. You write it once, you see “Hooray, it worked” in the console, and it feels like life is good. The problem is that after a few functions and a couple of if branches, your code starts to look like a chat with yourself: somewhere you print success, somewhere an error, somewhere “almost success,” and somewhere—silence. It becomes hard to test and hard to extend.

Imagine our console learning project (we’ve been evolving it for a while): a simple expense tracker. We have Expense (an expense object), a list of expenses in a MutableList, commands like add, list, remove. At some point a question comes up: how should a function report its result?

Approach What it looks like What’s wrong
Print inside the function
println("Added")
The function “both computes and talks.” Hard to reuse and test.
Return String?
success string or null on error
null poorly explains what went wrong. And it’s easy to forget to check.
Return a sealed result Ok/Error
Ok(message) / Error(reason)
A bit more code, but a clear contract and strict handling.

We choose the third option: it disciplines both the code and your brain. And the brain is a moody thing—better not to annoy it.

2. A single sealed result: Ok and Error

Now for an important idea that sounds simpler than it feels in practice. We want every “command operation” in the application to return the same result type, so handling is identical everywhere. Then in main we can write one nice when and not smear printing across the whole project.

Let’s start with the minimum:

sealed class CommandResult

data class Ok(val message: String) : CommandResult()
data class Error(val reason: String) : CommandResult()

Here, CommandResult is the base type, and Ok and Error are two variants (two concrete types) that inherit from it. In Kotlin, sealed hierarchies pair nicely with exhaustive when: the compiler knows the set of variants and helps you not forget a case.

Notice the naming convention. We intentionally use Ok/Error everywhere instead of bouncing between Success/Fail, Good/Bad, Fine/Oops. It’s a small thing, but it makes reading code faster: your eyes get used to the result always having the same shape.

3. Business functions: return CommandResult

Now let’s “ground” the idea in our expense tracker. Assume Expense and Category already exist (we introduced them earlier when we moved to classes and enum). For example, very simplified:

data class Expense(
    val amount: Int,
    val category: Category,
    val comment: String
)

enum class Category { FOOD, TRANSPORT, OTHER }

Now we write a function that adds an expense. Important: it does not print. It returns a result.

fun addExpense(
    expenses: MutableList<Expense>,
    amount: Int,
    category: Category,
    comment: String
): CommandResult {
    if (amount <= 0) return Error("Amount must be > 0")

    expenses.add(Expense(amount, category, comment))
    return Ok("Added expense: $amount in category ${category.name}")
}

Notice how linearly this reads: validate → if bad, return Error → otherwise add and return Ok. No “internal talking” via println(). We simply report the fact: success or error.

The same principle applies to removal. Say we remove by index:

fun removeExpense(expenses: MutableList<Expense>, index: Int): CommandResult {
    if (index !in expenses.indices) return Error("No expense with index $index")

    val removed = expenses.removeAt(index)
    return Ok("Removed expense: ${removed.amount} (${removed.category.name})")
}

Notice a nice thing: we use the removal result to form the message. If we printed inside, printing would be “embedded” into the function. But this way we return data (via a string) as part of the result contract.

4. when as a message dispatcher

The best part today is where sealed and when start working as a team.

We want a single function that turns a command result into a user-facing string. Let’s call it render(...). And here we make when an expression: it returns a string.

fun render(result: CommandResult): String =
    when (result) {
        is Ok -> "OK: ${result.message}"
        is Error -> "ERROR: ${result.reason}"
    }

Why this is great:

First, when here is an expression, meaning it returns a value. Kotlin requires such a when to be exhaustive.
Second, for a sealed type, an exhaustive when can be written without else, because all variants are known (in our case, exactly two). That means if tomorrow you add a third variant (for example, Help(...) or Empty(...)), the compiler will honestly say: “Hey buddy, you forgot to handle the new type.”

That’s the “seat belt” we want to get for free from the compiler.

5. Loop in main: got → rendered → printed

Now let’s assemble a typical CLI application loop. We won’t complicate the command parser (you’ve already had it earlier when you learned to parse strings and write utility functions). The main thing here is to show the data flow.

First—a loop sketch:

fun main() {
    val expenses = mutableListOf<Expense>()

    while (true) {
        print("> ")
        val input = readln()

        val result = handleCommand(expenses, input)
        println(render(result))
    }
}

Notice the beauty: main became a “coordinator,” not a dump of logic. It does only three things: reads a line, gets a result, prints the formatted message.

You can even draw a mini-diagram to make it easier on the brain:

flowchart TD
    A[readln: command string] --> B[handleCommand: logic]
    B --> C[CommandResult: Ok/Error]
    C --> D[render: user-facing string]
    D --> E[println]

And here something is very important: render separates the “internal truth of the program” from “how we talk to the user.” It’s a small bit of architecture, but it saves you from big chaos.

6. Command handling and layer responsibilities

The command handler returns CommandResult

Now we’ll make the simplest handleCommand. Yes, it will be quite “toy,” but the result contract is what matters.

Say we have commands:

  • add <amount> <category> <comment...>
  • list
  • remove <index>
  • exit

Here is a minimal handler (simplified parsing via split(), without quote support):

fun handleCommand(expenses: MutableList<Expense>, input: String): CommandResult {
    val parts = input.trim().split(" ")
    if (parts.isEmpty() || parts[0].isEmpty()) return Error("Empty command")

    return when (parts[0].lowercase()) {
        "list" -> listExpenses(expenses)
        "exit" -> Ok("Exiting the program")
        else -> Error("Unknown command: '${parts[0]}'")
    }
}

Notice: even exit we return as Ok for now. Why? Because within this lecture it’s important to show a single unified result flow. Later you can make a dedicated exit command and actually break the loop, but for now the Ok/Error logic matters more.

Let’s make listExpenses, which returns a multi-line message. We know StringBuilder (it appeared earlier in the course), so we’ll use it neatly:

fun listExpenses(expenses: List<Expense>): CommandResult {
    if (expenses.isEmpty()) return Ok("Expense list is empty")

    val sb = StringBuilder()
    for (i in expenses.indices) {
        val e = expenses[i]
        sb.append("$i) ${e.amount} ${e.category.name} — ${e.comment}\n")
    }
    return Ok(sb.toString().trimEnd())
}

We return Ok even for an “empty list,” because that’s not an error—it’s a normal operation result. And that’s an important discipline: an error is when a command cannot be executed correctly, not when the result is “uninteresting.”

Principle: do vs say

Here we arrive at a very practical principle: a function that performs an action should not simultaneously decide how exactly to report it to the user. It’s like in a cafe: the chef cooks, the waiter tells you what was brought. If the chef walks into the dining room and starts explaining the sauce to every guest, the restaurant quickly turns into stand-up comedy.

In code it looks like this:

  • addExpense(...) mutates the list and returns CommandResult.
  • render(...) knows how to print Ok/Error nicely (and consistently).
  • main decides when to print.

A very useful effect: you can change the message style (for example, remove OK:), and you won’t have to touch every business function. It’s enough to change render.

Why else in a when over a sealed type is a bad safety net

Now for a slightly provocative but friendly point: else in a when over a sealed type often looks like “I don’t want to think.” Sometimes it’s needed, but in our pattern it’s more often harmful.

Compare:

fun render(result: CommandResult): String =
    when (result) {
        is Ok -> "OK: ${result.message}"
        is Error -> "ERROR: ${result.reason}"
    }

And the “just in case” variant:

fun render(result: CommandResult): String =
    when (result) {
        is Ok -> "OK: ${result.message}"
        else -> "Something happened..."
    }

In the second case you lost the main benefit: if a new result variant appears, the compiler will no longer force you to handle it. It will say, “Well, you have else, so you’ve kind of covered everything.” But you actually covered nothing—you just slapped on a bandage.

And again: for a when expression, exhaustiveness is not a whim; it’s part of the language rules.
In our style we use this strictness as help, not as an obstacle.

Message separate from formatting

Sometimes there’s a temptation to do this: “Let Ok store a fully ready string including OK:,” and Error store one with ERROR:. Technically possible, but then you mix “result data” with “output formatting.” Today we keep discipline:

  • Ok.message — a meaningful success message (without decoration).
  • Error.reason — the reason for the error (without decoration).
  • Decoration (OK:, ERROR:) — only in render.

This makes the code cleaner and allows changing formatting without touching the logic.

If you want to be even stricter, you can add a tiny helper function:

fun printResult(result: CommandResult) {
    println(render(result))
}

And in main you’ll have:

val result = handleCommand(expenses, input)
printResult(result)

A small thing, but readability sometimes rests precisely on such small things.

7. Typical mistakes

Mistake #1: adding else in a when over a sealed type “just in case.”
This disables exhaustiveness checking and you lose the main advantage of the sealed model: the compiler will no longer signal that you forgot to handle a new variant. As a result, the program will keep compiling, but it may start producing weird messages in unexpected places.

Mistake #2: storing errors in Ok (“Ok, but message = 'Error: ...'”).
It looks harmless, but it breaks the meaning of the model: you can no longer tell what happened from the result type. When reading code, you’ll constantly doubt: “Is this success or just a string with complaints?” If it’s an error, it must be Error, not “success with a sad face.”

Mistake #3: returning null instead of Error.
The T? pattern is good for some tasks, but in command logic it’s too poor: null doesn’t explain the reason and is easy to miss. If an error is a normal outcome (for example, the user entered a wrong command), it’s better to return Error(reason) and handle it predictably.

Mistake #4: printing inside business functions while also returning a result.
Sometimes you see a hybrid: a function prints println("OK") and also returns Ok("..."). This leads to duplicated messages and confusion: where exactly is user output formed, who is responsible for it, why did something print twice. If you chose the CommandResult contract, it’s better to keep printing at the top level (in main or in a separate “UI” layer).

Mistake #5: making Ok too “thick” and Error too “thin.”
For example, Ok contains a message, data, and some stats, while Error is just a string "Error". In reality, errors often need no less clarity—at least a clear reason. Even if we only store a reason string for now, it’s important that it’s specific: “Amount must be > 0,” “Unknown command,” “Index out of range,” not just “Error.”

Mistake #6: turning a sealed result into “just another enum” by adding a bunch of nullable fields.
If you start writing something like data class Ok(val message: String, val removed: Expense?, val index: Int?), you gradually drift back to a “universal bag of nullables.” It’s better to keep variants simple and meaningful: either different result variants, or separate functions for different scenarios. Today’s goal is clarity of the contract, not maximum “universality at any cost.”

1
Task
Kotlin SELF,  34 level,  4 lecture
Locked
Unified response
Unified response
1
Task
Kotlin SELF,  34 level,  4 lecture
Locked
Positive number
Positive number
1
Task
Kotlin SELF,  34 level,  4 lecture
Locked
Withdrawal
Withdrawal
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet