Modeling UI State with Kotlin Sealed Interfaces: Let the Compiler Catch Your Missing Cases
Nullable data/error field pairs silently break when a new state is added. Kotlin sealed interfaces make the set of states closed, so the compiler flags every when that misses a case.
03 Aug 2026, 07:18 UTC

The bug that only shows up after a refactor
Your app has a screen that can be loading, showing data, or showing an error. You model that with a nullable data field and a nullable error field, and the UI checks them in order. It works — until someone adds a fourth state, "empty results," and forgets to update one of the three places that branch on the state. The compiler says nothing. The user sees a blank screen.
Kotlin's sealed classes and sealed interfaces exist for exactly this problem. They let you declare a closed set of variants, and the compiler enforces that every when over that set handles all of them. Add a variant later, and every incomplete when becomes a compile error. The refactor that used to be a silent bug becomes a list of places to fix.
What "sealed" actually restricts
A sealed class or interface can only be subclassed in the same package and module (for sealed interfaces, same compilation unit). That restriction is the whole point: because the compiler can see every possible subclass, it can check exhaustiveness. A when used as an expression over a sealed type does not need an else branch — and if you leave a case out, the code does not compile.
Two version notes worth knowing: sealed classes have been stable since Kotlin 1.1, and sealed interfaces since Kotlin 1.5. Sealed interfaces are usually the better default today because a type can implement several of them, and they avoid forcing your variants into a single class hierarchy. The examples below assume Kotlin 1.5 or newer.
A worked example: screen state
Here is the nullable-fields version replaced with a sealed interface:
sealed interface ScreenState {
data object Loading : ScreenState
data class Content(val items: List<String>) : ScreenState
data class Failure(val message: String, val cause: Throwable?) : ScreenState
}
fun render(state: ScreenState): String = when (state) {
is ScreenState.Loading -> "spinner"
is ScreenState.Content -> "list of ${state.items.size} items"
is ScreenState.Failure -> "error: ${state.message}"
}Three things to notice. First, each variant carries exactly the data it needs — Content has items, Failure has a message, Loading has nothing. You can no longer represent the nonsense combination "loading with an error and also data." Second, the when has no else. Third, inside each branch the compiler smart-casts state, so state.items just works.
Now the payoff: add a new variant, say data object Empty : ScreenState, and recompile. Every exhaustive when over ScreenState that lacks an Empty branch fails to compile. That is the verification step built into the language — you do not need a test to discover the missed branch, though you should still test the behavior.
Exhaustiveness has conditions
The guarantee only applies when the when is over the sealed type directly. If you when over a derived value (like state.toString()) or the subject's static type is a non-sealed supertype, the compiler cannot check anything and you are back to needing else. Also, a when used as a statement rather than an expression historically did not require exhaustiveness in older Kotlin versions; using it as an expression (assigning or returning the result) is the reliable way to get the check across versions.
To confirm the behavior on your own toolchain, a quick experiment: define the sealed interface above with two variants, write the when without else, then add a third variant and recompile with ./gradlew compileKotlin (run from the project root; requires only a normal JDK and Gradle setup). You should see a compile error pointing at the non-exhaustive when. Exact error wording varies by compiler version, so do not script against the message text — just check that compilation fails and succeeds again once you add the branch.
Trade-offs and when not to reach for sealed
Sealed types are not free. The closed-world restriction means consumers of a published library cannot add their own variants — which is sometimes exactly what you want for a domain model, and sometimes a real constraint for extensible APIs. If third parties legitimately need to plug in new cases, use an open interface plus a registry or visitor pattern instead, and accept that you lose exhaustiveness.
There is also a readability cost when variants multiply. A sealed interface with twelve variants and five big when expressions can be harder to navigate than polymorphic dispatch. A reasonable heuristic: sealed types shine for small, stable, closed sets (state machines, parse outcomes, result types) where "did you handle every case?" is the question that matters. For open-ended plugin points, they fight you.
The actionable part
Next time you catch yourself writing paired nullable fields — data? and error?, or a Boolean flag plus a payload — stop and ask whether the legal combinations form a closed set. If they do, model it as a sealed interface, make each variant carry only its own data, and write your when expressions without else. You will trade a few minutes of upfront modeling for a compiler that taps you on the shoulder every time a future change leaves a case unhandled.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.