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
elsebranch. 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 knowsresultis aSuccess, soresult.dataworks with no cast — the smart cast from null safety, applied to types. - The safety guarantee. If you add a fourth subtype later, every exhaustive
whenthat handlesResultstops 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 (Successhas data,Failurehas 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)orFailure(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
- Write the
sealed class ResultwithSuccess,Failure, andLoading. Create one of each. - Write
render(result): String = when (result) { ... }handling all three, with noelse. Confirm it compiles. - Access
result.datainside theis Result.Successbranch and confirm no cast is needed (the smart cast). - Add a fourth subtype (
object Empty : Result()) and watchrenderstop compiling. Add the case to fix it — notice the compiler led you here. - Model a
TrafficLightas anenumand aPaymentState(with per-case data) as a sealed class. Justify each choice. - Write a
whenover the enum and confirm it, too, is exhaustive withoutelse. - 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
whenthat handles it.
Official documentation
- Kotlin — Sealed classes and interfaces — Declaration, the same-file rule, and exhaustive
when. - Kotlin — Enum classes — The simpler fixed-set-of-values alternative.
- Kotlin — when expressions — Exhaustiveness with sealed types and enums.
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