Sealed classes: modeling a finite set of cases without a cascade of if/else

In the article on Kotlin, a table mentions sealed classes in a single row, next to data classes and null-safety. That point deserves a full article on its own: it's the language's most underused tool for modeling a business result, and the one whose absence costs the most in production.
The problem a sealed class solves
Modeling the result of a business validation with a plain enum or a status string works, until the day two cases of the enum need to carry different data: a rejection needs a reason, a pending review needs a due date, an acceptance needs nothing. The most common fallback, an object with optional fields for each case, leaves inconsistent combinations possible (an "approved" status with a rejection reason filled in somewhere in the code) that the compiler can't catch.
sealed class LimitCheckResult {
data object Approved : LimitCheckResult()
data class Rejected(val reason: String) : LimitCheckResult()
data class PendingReview(val reviewBy: LocalDate) : LimitCheckResult()
}
Each case only carries the data relevant to it: Approved has no fields,
Rejected requires a reason, PendingReview requires a date. No
inconsistent combination is representable.
What exhaustiveness actually changes
The real benefit doesn't come from the declaration, it comes from usage: an
exhaustive when over a sealed class forces every case to be handled, and
the compiler refuses to build if a case is missing, including when a new
case is added months later.
fun describe(result: LimitCheckResult): String = when (result) {
is LimitCheckResult.Approved -> "Approved"
is LimitCheckResult.Rejected -> "Rejected: ${result.reason}"
is LimitCheckResult.PendingReview -> "Pending until ${result.reviewBy}"
}
If PendingReview is added six months after this function was written,
every existing exhaustive when in the codebase refuses to compile until
it's updated. With a plain enum and a non-exhaustive when (or worse, a
cascade of if/else if), that same oversight compiles without error, and
the new case silently falls into an else branch that was never designed
for it.
| Approach | New case forgotten in a handler | Detection |
|---|---|---|
Enum + when with else | Compiles, falls into the else branch | None, discovered in production |
Enum + cascading if/else if | Compiles, falls into the last else | None, discovered in production |
Sealed class + exhaustive when | Doesn't compile | Immediate, at compile time |
The catch: adding a case becomes a breaking change for others
The exhaustiveness of when relies on the compiler knowing every
possible subtype of a sealed class at compile time; that's exactly why a
sealed class stays closed to its declaring module, no external module can
add a subtype to it. What deserves anticipating is the consequence for
consumers: if that sealed class is part of a library used by other teams,
adding a new case breaks the build of every existing exhaustive when on
their side, the moment they recompile against the new version. That's
exactly the behavior you want inside a single team (nothing should get
forgotten), but it becomes a breaking API change, one to document and
version as such, the moment a sealed class crosses the boundary of a
shared library.
Sealed class vs enum: when to use which
| Situation | Right choice |
|---|---|
| Fixed cases with no associated data (days of the week, simple statuses) | enum class |
| Cases that each carry different data | sealed class or sealed interface |
| Need to verify handling is exhaustive at compile time | sealed class with an exhaustive when |
| Simple serialization to a database (a single column) | enum class, more direct |
The deciding factor isn't stylistic preference, it's the question "does each case need different data, and do I want adding a case to break the build everywhere it isn't handled?". If the answer to both is yes, the sealed class isn't one option among others, it's the only tool in the language that offers that guarantee.