JavaRush/Courses/Kotlin SELF/Getting to know sealed class

Getting to know sealed class

Open

1. Enum and sealed: when and why

If programming were a kitchen, then enum would be a set of spice jars, with one word on each jar: SALT, PEPPER, PAPRIKA. And a sealed class is a set of containers, where one container might be “vegetable soup”, another “noodle soup”, and a third “empty pot (error: you forgot to turn on the stove)”. And each container has its own contents.

An enum class answers the question “which option is selected?” very well. But sometimes that is not enough: you want different options to carry different data. For example, when executing a command in an application:

  • on success you want to return, say, the created expense id and a confirmation message;
  • on error: the reason, possibly the problematic field, possibly a hint for the user.

If you try to do this with an enum, you will quickly end up with a construction like “the enum has a bunch of fields, half of which are always null”, and that is exactly the kind of code smell that (like the smell of burnt wiring) is better caught early.

Technically, Kotlin lets you model this with a sealed class: you define a base type “Operation result”, and then explicitly list the allowed variant types: for example Ok and Error.

Enum vs sealed: a quick cheat sheet

Situation Better: enum class Better: sealed class
Options are just values from a list Yes Possible, but overkill
Each option has its own data Usually awkward Yes, that is the direct purpose
We want “success / error” with details You can do it “head-on”, but it is often messy Yes, the ideal use case
We want to guarantee no options appear “from the side” Partially Yes, a closed family
All options have the same shape Yes Not required

And now an important practical point: sealed is not “advanced for the sake of being advanced”. It is a way to make your program tell the truth with types. If an operation can end in success or in error, then the result type should reflect that.

2. The core idea of sealed: a closed family of types

Before writing code, it is important to understand the meaning of sealed in plain language. The key idea: a sealed class defines a closed family of types. That is, you tell the compiler: “Here is the base type, and here is the list of variants, and there must be no other variants in this family (at least not suddenly somewhere else in the project)”.

Historically and conceptually, sealed types were created so the compiler could “understand” exhaustiveness when branching by variants. The Kotlin specification discusses this directly: sealed hierarchies are needed so that when can be exhaustive, because the compiler knows the full set of inheritors.

But in this lecture we will not dive deep into handling variants via is and an exhaustive when—that is the next lecture. Today our task is simpler and more practical: learn to create a sealed model and return it from functions, so you can stop using “magic strings”, null, and non-obvious flags.

3. Minimal example: Ok and Error

Now we will write the smallest possible sealed result. I will deliberately make the variants simple and short—so you do not drown in details.

Notice one practical trick: you can keep the variants “inside” the sealed class. Then the names Ok and Error do not pollute the whole project, and at the usage site it is immediately clear which family they belong to.

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

There are a few important points here.

First, CommandResult is the base type. We typically do not instantiate it directly. We create variants: CommandResult.Ok(...) or CommandResult.Error(...).

Second, variants are ordinary classes. And very often it is convenient to make them data class, because they really “hold data”. Kotlin explicitly allows a data class to extend other classes, including sealed ones (while the data class itself cannot be sealed).

4. Example from a CLI expense tracker

From here, we will start tying the examples into a single application. Suppose we have a simple console expense tracker (a familiar and quite real-life idea: money disappears faster than lines of code—especially if you write lines of code in a coffee shop).

Assume we already have an expense model and categories (it is convenient to store the category as an enum—that is exactly a “fixed set of values”).

enum class Category {
    FOOD, TRANSPORT, OTHER
}

data class Expense(
    val id: Int,
    val title: String,
    val amount: Double,
    val category: Category
)

Now imagine the add command: the user enters a title, an amount, and a category, and we must either add the expense or explain why it did not work.

Why returning just String is bad

A typical “first” approach looks like this: the function returns text that we print.

The problem is that a string does not carry a strict meaning. A string can be both success and error—external code has to “guess” from its contents.

And then something like this appears:

  • "OK: added" — success
  • "ERROR: invalid amount" — error

And suddenly you start parsing your own strings. It is roughly like writing “Food here” on a box, and then trying to figure out by smell whether it is food or batteries.

A sealed result for the add command

Let’s make the result a bit richer: on success we will store expenseId, and on error—a clear reason.

sealed class AddExpenseResult

data class AddOk(val expenseId: Int, val message: String) : AddExpenseResult()
data class AddError(val reason: String) : AddExpenseResult()

Yes, the names are a bit longer here. But that “longer” buys you clarity: you physically cannot mix up success and error.

5. Returning sealed from a function: error as a normal outcome

Now comes an important psychological point for beginners. An error is not necessarily an exception. Very often an error is just an expected outcome of an operation: the user can type nonsense, a file can be missing, the amount can be negative. This is not a “catastrophe”; it is just a branch of the scenario.

That is exactly why a sealed result works so well: we return a value that says, “here is what happened”.

Let’s make a function for adding an expense. Let the storage be MutableList<Expense> (at this stage, that is fine).

fun addExpense(
    expenses: MutableList<Expense>,
    title: String,
    amount: Double,
    category: Category
): AddExpenseResult {
    if (title.isBlank()) return AddError("Title must not be blank")
    if (amount <= 0.0) return AddError("Amount must be greater than 0")

    val newId = (expenses.maxOfOrNull { it.id } ?: 0) + 1
    expenses.add(Expense(newId, title, amount, category))

    return AddOk(expenseId = newId, message = "Expense added")
}

Notice how linearly the code reads: “checked → returned error → otherwise did it → returned success”. This style is often called fail-fast: as soon as we understand the operation is impossible, we immediately return a clear result.

6. A shared result for different commands

In the example above we made AddExpenseResult, but you will quickly notice: you will end up with RemoveExpenseResult, UpdateExpenseResult, ParseCommandResult, and so on.

And at this point you want a universal result “for commands in general”. A very common convention is the variants Ok and Error. It is good because in a when (which we will cover later) the code reads like: “if Ok—do one thing; if Error—do another”.

We already have such a type: CommandResult from the minimal example. Let’s use it.

fun addExpenseSimple(
    expenses: MutableList<Expense>,
    title: String,
    amount: Double,
    category: Category
): CommandResult {
    if (title.isBlank()) return CommandResult.Error("Title must not be blank")
    if (amount <= 0.0) return CommandResult.Error("Amount must be greater than 0")

    val newId = (expenses.maxOfOrNull { it.id } ?: 0) + 1
    expenses.add(Expense(newId, title, amount, category))

    return CommandResult.Ok("Added: id=$newId")
}

The important thing here: we did not make the inheritance “deep” and complicated. Just two variants. But even now the result type became honest.

7. A wider-shaped result: different data for success and error

Now let’s do what sealed is often used for: success and error have different “shapes” of data. For example:

  • a successful add returns Expense
  • an add error returns reason and badInput

Doing this cleanly with enum is hard, but with sealed it is natural.

sealed class AddExpenseResult2

data class AddOk2(val expense: Expense) : AddExpenseResult2()
data class AddError2(val reason: String, val badInput: String) : AddExpenseResult2()

And the function:

fun addExpenseDetailed(
    expenses: MutableList<Expense>,
    title: String,
    amountText: String,
    category: Category
): AddExpenseResult2 {
    val amount = amountText.toDoubleOrNull()
        ?: return AddError2("Amount must be a number", badInput = amountText)

    if (amount <= 0.0) return AddError2("Amount must be greater than 0", badInput = amountText)

    val newId = (expenses.maxOfOrNull { it.id } ?: 0) + 1
    val expense = Expense(newId, title, amount, category)
    expenses.add(expense)

    return AddOk2(expense)
}

Notice how neatly we avoided null logic. We do not return Expense?, where null means “not added”. We return a value that explicitly says “which exact outcome happened”.

8. Where to keep the variants and how the pipeline works

Where to declare the variants

From Kotlin rules: a sealed hierarchy restricts where its inheritors can be located (in modern Kotlin versions—in the same module and package). For a beginner, the most practical advice is simpler: keep the base sealed class and its variants in the same file while the project is small.

First, it is easier to read: you open the file and immediately see “what variants of the result exist at all”.

Second, there are fewer surprises: nobody will add “one more variant somewhere over there” and make you hunt for it across the project.

You can think of it this way: a sealed class is like a folder with documents labeled “Inside only: passport, driver’s license, and certificate”. And you really want all of that in one folder, not scattered around the apartment.

Pipeline diagram: command → result

To avoid losing the big picture, it helps to see the process “command → logic → result” as a mini pipeline.

flowchart TD
    A[User input: command string] --> B[Parsing and validation]
    B -->|valid| C[Perform action]
    B -->|error| E["Return Error(...)"]
    C -->|success| D["Return Ok(...)"]

sealed here is like a neat container for the output of steps B and C. Instead of “printing inside” or “throwing exceptions for every sneeze”, the function returns what happened, and the outer code decides what to do with it.

Sealed for errors and exceptions

Can you use sealed for exceptions? Yes: sealed can be the base for an error hierarchy (including exceptions), to make errors more structured.

But within our topic it is important not to mix up two approaches.

  • When an error is an expected part of the scenario (invalid input, empty title, negative amount), it is often more convenient to return a sealed result.
  • When an error is an unexpected breakage (infrastructure failed, internal invariants were violated, “this must not happen”), then it is more often about exceptions.

Today we intentionally practice the first style: “error as a value”, but without Result<T>— purely with sealed class, so it is easier for you to feel the mechanics.

9. Typical mistakes when designing sealed results

Mistake #1: trying to cram everything into one class with lots of nullable fields.
Beginners often do something like class Result(val ok: Boolean, val message: String?, val reason: String?, val expense: Expense?). Formally it works, but the logic becomes implicit: which fields must be filled when ok=true? Which when ok=false? A sealed model solves this with types: Ok contains only what makes sense on success, and Error— only what makes sense on error.

Mistake #2: “success” with an error inside.
Sometimes you see something like Ok(message = "ERROR: ..."). That breaks the meaning of the model: if you returned Ok, external code must be allowed to trust that everything is fine. If something went wrong—it must be Error, otherwise the type stops being a contract and turns into decoration.

Mistake #3: inconsistent variant naming.
In one place Success/Failure, in another Good/Bad, in a third Ok/Error. The reader of the code (including you in two weeks) starts spending effort on “switching dictionaries”. Pick one convention (in our course—Ok/Error) and stick to it; then result handling will look the same across the whole project.

Mistake #4: returning null instead of Error.
null means “no value”, but it is almost never an “explanation of the problem”. When you return null, you lose the reason and force the caller to guess. A sealed error is good because it carries data: why it failed, what exactly was wrong, what to show the user.

Mistake #5: mixing command execution and console output.
It is very tempting to do println("Added!") inside addExpense(...). But then the function becomes inflexible: you cannot use it somewhere else (for example, in tests or in another UI), and it is hard to control output. It is better to return Ok/Error, and do printing outside—where you decide “how exactly we show the result to the user”.

1
Task
Kotlin SELF,  34 level,  2 lecture
Locked
Nickname registration
Nickname registration
1
Task
Kotlin SELF,  34 level,  2 lecture
Locked
Temperature Conversion
Temperature Conversion
1
Task
Kotlin SELF,  34 level,  2 lecture
Locked
Item quantity
Item quantity
1
Task
Kotlin SELF,  34 level,  2 lecture
Locked
Discount from a line
Discount from a line
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet