Model UI States with Kotlin Sealed Types
Model mutually exclusive UI states with Kotlin sealed types so the compiler flags missing branches instead of letting invalid states slip through runtime.
11 Nov 2025, 08:30 UTC

The problem: states that can be true at the same time
A common way to model a screen state looks like this:
data class UserScreenState(
val user: User?,
val error: Throwable?,
val isLoading: Boolean
)
This type allows impossible combinations: a user and an error at once, or loading while a user is already displayed. Every consumer must remember to check all three fields, and a missed check becomes a runtime bug.
Thesis: make illegal states unrepresentable
A sealed type is a class or interface whose direct subtypes are fixed at compile time. Kotlin uses that closed list to verify that a when expression handles every subtype. If you omit one, the code does not compile. This is called exhaustiveness checking.
Sealed classes versus sealed interfaces
Use a sealed class when subtypes share state or behavior and must live in the same file as the base. Use a sealed interface when subtypes are independent records or when you want implementations spread across files in the same module. Sealed interfaces have been stable since Kotlin 1.5; sealed classes date to Kotlin 1.0.
Worked example: screen states
Model the screen as one of three mutually exclusive states:
sealed interface ScreenState<out T> {
object Loading : ScreenState<Nothing>
data class Success<T>(val data: T) : ScreenState<T>
data class Error(val exception: Throwable) : ScreenState<Nothing>
}
Render it without an else branch:
fun render(state: ScreenState<String>) = when (state) {
is ScreenState.Loading -> showSpinner()
is ScreenState.Success -> showData(state.data)
is ScreenState.Error -> showError(state.exception)
}
Because ScreenState is sealed, the compiler knows these are the only options. Remove the Error branch and the build fails with an unhandled-case error rather than shipping a missing branch.
How to verify the behavior
- Open a terminal in the project root, where build.gradle.kts or pom.xml lives.
- Confirm the Kotlin plugin is version 1.5 or later, for example kotlin("jvm") version "1.9.0" in Gradle.
- Compile with ./gradlew compileKotlin (Gradle) or mvn compile (Maven). No special permissions are required beyond normal file access.
- Temporarily delete one branch from a when over a sealed type and recompile. Expect an error such as 'when' expression must be exhaustive. Restore the branch to return to a successful build.
Risk: adding a new subtype to a sealed hierarchy is source-breaking. Every exhaustive when across the module stops compiling until it handles the new case. That is usually desirable, but it means sealed types are less suitable for hierarchies that external libraries must extend.
Limitation to remember
Sealed only closes the inheritance hierarchy. It does not pattern-match on arbitrary fields, and it does not stop you from instantiating an abstract sealed type directly if you somehow bypass the type system. It trades flexibility for compile-time certainty.
Closing action
Next time you reach for nullable fields or a boolean flag to represent mutually exclusive conditions, define a sealed interface instead. Compile, drop a branch, watch the build fail, then restore it. If the compiler catches the gap, your hierarchy is working as intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.