Riadh Mnasri
← Back to blog
3 min read

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.

ApproachNew case forgotten in a handlerDetection
Enum + when with elseCompiles, falls into the else branchNone, discovered in production
Enum + cascading if/else ifCompiles, falls into the last elseNone, discovered in production
Sealed class + exhaustive whenDoesn't compileImmediate, 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

SituationRight choice
Fixed cases with no associated data (days of the week, simple statuses)enum class
Cases that each carry different datasealed class or sealed interface
Need to verify handling is exhaustive at compile timesealed 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.