Kotlin Sealed Interfaces: Make Missing Request States a Compile Error
Kotlin sealed interfaces replace ambiguous nullable flags with explicit request states. Here is a worked example, a compile-time verification plan, and the trade-offs to consider.
26 Feb 2026, 03:05 UTC

The problem: “success” is not always just a value
A request can succeed, fail with a known error, or arrive in a state your UI has not learned to render. Nullable flags and integer status codes let those cases drift: one branch forgets a state, and the mistake appears at runtime. The useful takeaway is to model a closed set of outcomes as Kotlin types, then let the compiler find unhandled branches.
Make the possible outcomes explicit
A sealed interface defines a closed hierarchy: the compiler knows its permitted direct implementations. Data classes are a practical way to implement it because each state keeps only the payload it needs.
sealed interface RequestResult {
data class Success(val value: String) : RequestResult
data class Failure(
val code: String,
val message: String
) : RequestResult
}
Unlike a nullable result, this representation does not require readers to infer meaning from a combination of values. Success contains a value; Failure contains an error code and message. There is no ambiguous state where both the value and the error are present.
Use a when expression as a contract
A when expression must produce a value. When its subject is a sealed type, Kotlin can require every permitted subtype to be covered. That makes a missing state a compilation error rather than a runtime surprise.
fun render(result: RequestResult): String =
when (result) {
is RequestResult.Success ->
"Success: ${result.value}"
is RequestResult.Failure ->
"Failure ${result.code}: ${result.message}"
}
Here, is checks the concrete subtype. No else branch is needed: the compiler knows that Success and Failure are the complete set. If a Timeout subtype is added, unhandled when expressions will no longer compile until they account for it.
| Representation | Compiler help | Main cost |
|---|---|---|
| Nullable value plus error | None for possible combinations | Ambiguous null states |
| Integer status code | None | Magic values and scattered mappings |
| Sealed interface | Missing subtypes break exhaustive expressions | Closed hierarchy and update churn |
Validate external data before creating a sealed state
Compile-time exhaustiveness does not validate JSON, database rows, or network responses. Keep that validation at the boundary, where arbitrary strings become trusted domain states.
fun fromResponse(status: String, body: String): RequestResult =
when (status) {
"ok" -> RequestResult.Success(body)
"error" -> RequestResult.Failure("remote_error", body)
else -> RequestResult.Failure(
"unexpected_status",
"Unsupported status: $status"
)
}
This simplified boundary checks the status only. A production implementation should also use the appropriate JSON or protocol parser and reject malformed bodies before constructing Success. The else branch is appropriate here because external status strings form an open set; it would undermine the internal contract if used for the sealed result itself.
Verify the behavior with the compiler you will ship
The example assumes Kotlin 1.5 or newer, where sealed interfaces are stable, and uses no newer language feature. Diagnostic wording and build task names can vary, so verify against the project's pinned toolchain rather than assuming a global compiler matches it.
- Run
kotlinc -versionin a terminal to check the installed compiler. No special permissions are required. This confirms the standalone compiler version, but not necessarily the version used by Gradle. - Save the example as
sealed-result.kt. From the directory containing that file, runkotlinc sealed-result.kt -d sealed-result.jar. Replace the filename with your actual file. The command needs write permission in the current directory, needs no elevated privileges, and may overwrite the named jar. A successful compilation is the expected check. - Temporarily add a
Timeoutdata class implementingRequestResult, then compile again. The compiler should report non-exhaustiveness at unhandledwhenexpressions. Remove the temporary subtype and recompile; this check is safer in a scratch branch than in unreviewed production changes. - In a standard Gradle JVM project, run
./gradlew compileKotlinfrom the project root. The wrapper may download Gradle or dependencies and write build caches, so network access and normal user write permissions may be required. The task name can differ in other project layouts.
Where sealed interfaces stop being the right fit
- The hierarchy is closed. Implementations must remain in the same package and module as the sealed interface, so external libraries cannot add new states.
- Adding a subtype can create compilation work across several branches. That is useful feedback, but it becomes costly if states change frequently.
- Prefer data classes over deep base-class inheritance. Each state then owns an explicit payload instead of inheriting ambiguous behavior.
- Exhaustiveness protects internal branching, not input correctness. Boundary parsing still needs tests for malformed or unsupported values.
A practical adoption sequence
- List the fixed outcomes and the data each one needs.
- Replace one nullable-flag or status-code cluster with a sealed interface.
- Write exhaustive
whenexpressions without anelsebranch. - Compile with the project toolchain and temporarily add a state to confirm the intended failures.
- Test the boundary separately with valid, malformed, and unknown external values.
Use sealed interfaces when the set of domain outcomes is genuinely closed and benefits from compile-time coverage. If other modules must extend the result set, or the states change constantly, a validated open representation may be the more maintainable choice.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.