RizTech Academy logo
RizTech Academy
Classes and ObjectsLesson 4 of 525 min

Sealed classes and exhaustive when

Sealed classes are one of Kotlin's genuine highlights — a feature that, combined with when, lets the compiler guarantee you have handled every possible case. Once you have modelled a few things with them, you will miss them in every language that lacks them. This lesson is what they are and the pattern they enable.

The problem: a fixed set of possibilities

Many things in a program are "one of a fixed set of kinds". A network request is loading, or succeeded with data, or failed with an error. A payment is pending, completed, or refunded. You want to model "it is exactly one of these, and here are the possibilities", and then handle each.

A plain interface almost does it — but nothing stops someone, somewhere, adding a new implementation you did not anticipate, and the compiler cannot know the set is complete. A sealed class closes the set: it declares "these are all the possible subtypes, and there are no others".

Declaring a sealed hierarchy

sealed class Result {
    data class Success(val data: String) : Result()
    data class Failure(val error: String) : Result()
    object Loading : Result()
}

Read it: Result is sealed, and it has exactly three subtypes — Success (carrying data), Failure (carrying an error message), and Loading (a stateless singleton, hence object — the next lesson). Crucially, all subtypes must be declared in the same file (or nested, as here), which is what lets the compiler know the complete set. Nobody can add a fourth Result type elsewhere.

Each subtype can carry its own data — Success has data, Failure has error, Loading has nothing. This is more expressive than a plain enum, where every value is identical in shape; a sealed class lets each case carry exactly the data that case needs.

The payoff: exhaustive when

Now handle a Result with when, and watch the compiler work for you:

fun render(result: Result): String = when (result) {
    is Result.Success -> "Loaded: ${result.data}"
    is Result.Failure -> "Error: ${result.error}"
    is Result.Loading -> "Please wait..."
    // no 'else' needed — the compiler knows these are ALL the cases
}

println(render(Result.Success("42 orders")))    // Loaded: 42 orders
println(render(Result.Failure("network down")))  // Error: network down
println(render(Result.Loading))                  // Please wait...

Three things make this special:

  • No else branch. Because the compiler knows the complete set of subtypes, it can verify you have covered them all. This is the exhaustiveness from the conditions module, now airtight.
  • Smart casts. Inside is Result.Success ->, the compiler knows result is a Success, so result.data works with no cast — the smart cast from null safety, applied to types.
  • The safety guarantee. If you add a fourth subtype later, every exhaustive when that handles Result stops compiling until you add the new case. The compiler walks you to every place that needs updating. This is enormous: adding a case can never silently leave a code path unhandled.

That last point is the whole reason sealed classes matter. In a language without them, adding a new kind means hunting through the codebase for every switch that handles the type and hoping you found them all — and the ones you missed become runtime bugs. With a sealed class, the compiler finds them all for you, at compile time. It turns "did I handle the new case everywhere?" from a manual search into a guarantee.

sealed class versus enum

Both model "one of a fixed set". The difference:

  • enum — a fixed set of values, all the same shape. enum class Direction { NORTH, SOUTH, EAST, WEST }. Use it when the cases are simple constants with no per-case data.
  • sealed class — a fixed set of types, each able to carry different data. Use it when the cases need to hold different information (Success has data, Failure has an error).

If your cases are just labels, use an enum. If they carry data, use a sealed class. Both give you exhaustive when.

Where you will use this

Sealed classes are everywhere in real Kotlin, especially Android:

  • UI state — Loading, Content(data), Error(message) — the exact example above, and the standard way to model a screen's state.
  • Results of an operation — Success(value) or Failure(exception), a type-safe alternative to throwing exceptions (the errors lesson in the best-practices module returns to this).
  • Events and actions — a fixed set of things the user can do, each with its own payload.
  • A parsed value — IntValue(n), StringValue(s), Missing.

Whenever you catch yourself thinking "this is one of a few specific kinds, and I want to handle each", that is a sealed class. Modelling your domain this way — making illegal states unrepresentable, and letting the compiler enforce that every case is handled — is one of the marks of well-designed Kotlin, and the capstone uses it for exactly this.

Check your work

What a sealed class models. A fixed, closed set of subtypes — "it is exactly one of these, and there are no others".

How the compiler knows the set is complete. All subtypes are declared in the same file (or nested), so nothing can add one elsewhere.

How a sealed class differs from an enum. Enum: a fixed set of same-shaped values; sealed class: a fixed set of types, each able to carry different data.

Why when on a sealed type needs no else. The compiler knows every case, so it can verify exhaustiveness.

What smart casts do here. Inside is Result.Success ->, result is treated as a Success, so its data is accessible with no cast.

What happens when you add a new subtype. Every exhaustive when stops compiling until it handles the new case — the compiler finds every place to update.

Why that guarantee matters. It turns "did I handle the new case everywhere?" from a manual, error-prone search into a compile-time certainty.

Three common uses. UI state, operation results, and events/actions.

Practice

  1. Write the sealed class Result with Success, Failure, and Loading. Create one of each.
  2. Write render(result): String = when (result) { ... } handling all three, with no else. Confirm it compiles.
  3. Access result.data inside the is Result.Success branch and confirm no cast is needed (the smart cast).
  4. Add a fourth subtype (object Empty : Result()) and watch render stop compiling. Add the case to fix it — notice the compiler led you here.
  5. Model a TrafficLight as an enum and a PaymentState (with per-case data) as a sealed class. Justify each choice.
  6. Write a when over the enum and confirm it, too, is exhaustive without else.
  7. Model a small piece of an app you know — a screen's state, or an operation's outcome — as a sealed class, and write the exhaustive when that handles it.

Official documentation

Next: object, companion object, and singletons.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship