Introduction
Kotlin sealed classes and interfaces restrict the hierarchy to a known set of subtypes, enabling the compiler to verify that when expressions handle all possible cases. When a new subclass is added to a sealed hierarchy, all existing when expressions that do not have an else branch will fail to compile with "expression is incomplete" errors. While this is a feature for type safety, it can cause widespread compilation failures. The proper approach is to handle each case explicitly and use the compiler errors as a guide for updating all affected code paths.
Symptoms
- "When expression is exhaustive, please provide 'else' branch" or "'when' expression must cover all cases"
- Compilation error after adding new sealed class subclass
- Runtime
elsebranch masking missing case handling - Smart cast not working in when branches
- Sealed interface in different module not recognized
Error output:
``
e: file:///app/src/main/java/com/example/ui/OrderScreen.kt:45:9
'when' expression must be exhaustive, add necessary 'Loading' branch or 'else' branch instead
Common Causes
- New subclass added to sealed hierarchy without updating all when expressions
- Using
elsebranch instead of explicit case handling (masks missing cases) - Sealed class defined in different module not recompiled
- Smart cast failing due to nullable types or mutable properties
- Sealed interface subtypes spread across multiple files not all imported
Step-by-Step Fix
- 1.Handle all sealed class cases explicitly:
- 2.```kotlin
- 3.sealed class UiState<out T> {
- 4.data object Loading : UiState<Nothing>()
- 5.data class Success<T>(val data: T) : UiState<T>()
- 6.data class Error(val message: String, val throwable: Throwable? = null) : UiState<Nothing>()
- 7.}
// CORRECT - all cases handled explicitly @Composable fun OrderScreen(state: UiState<Order>) { when (state) { is UiState.Loading -> LoadingIndicator() is UiState.Success -> OrderDetails(state.data) is UiState.Error -> ErrorMessage(state.message) } // No 'else' branch - compiler ensures all cases are handled }
// When you add a new case: sealed class UiState<out T> { data object Loading : UiState<Nothing>() data class Success<T>(val data: T) : UiState<T>() data class Error(val message: String, val throwable: Throwable? = null) : UiState<Nothing>() data object Empty : UiState<Nothing>() // NEW }
// Compiler errors point to EVERY when expression that needs updating // Fix each one: @Composable fun OrderScreen(state: UiState<Order>) { when (state) { is UiState.Loading -> LoadingIndicator() is UiState.Success -> OrderDetails(state.data) is UiState.Error -> ErrorMessage(state.message) is UiState.Empty -> EmptyState() // Added - compiler forced this } } ```
- 1.Use sealed interface for flexible hierarchies:
- 2.```kotlin
- 3.// Sealed interface allows subtypes in different files/packages
- 4.sealed interface NetworkResult<out T> {
- 5.data class Success<T>(val data: T) : NetworkResult<T>
- 6.data class Error(val code: Int, val message: String) : NetworkResult<Nothing>
- 7.data object Loading : NetworkResult<Nothing>
- 8.}
// Subtypes can be in different files data object NetworkResult.Idle : NetworkResult<Nothing>
// When expression must handle ALL subtypes including those in other files fun handleResult(result: NetworkResult<String>) { when (result) { is NetworkResult.Success -> println("Data: ${result.data}") is NetworkResult.Error -> println("Error ${result.code}: ${result.message}") is NetworkResult.Loading -> println("Loading...") is NetworkResult.Idle -> println("Not started") } } ```
- 1.Avoid else branch - use it as intentional default with warning:
- 2.```kotlin
- 3.// BAD - else masks missing cases
- 4.when (state) {
- 5.is UiState.Loading -> LoadingIndicator()
- 6.is UiState.Success -> OrderDetails(state.data)
- 7.else -> ErrorMessage("Unknown state") // Masks UiState.Empty!
- 8.}
// GOOD - use @Suppress only when you intentionally want a default // and document why when (state) { is UiState.Loading -> LoadingIndicator() is UiState.Success -> OrderDetails(state.data) is UiState.Error -> ErrorMessage(state.message) is UiState.Empty -> EmptyState() // No else - compiler catches future additions }
// When you need a return value, use as expression val message = when (state) { is UiState.Loading -> "Loading..." is UiState.Success -> "Loaded ${state.data.size} items" is UiState.Error -> "Error: ${state.message}" is UiState.Empty -> "No items found" } ```
- 1.Handle nullable sealed class references:
- 2.```kotlin
- 3.// Smart cast doesn't work with nullable sealed class
- 4.val state: UiState<Order>? = _stateFlow.value
// WRONG - smart cast fails when (state) { is UiState.Loading -> {} // Error: smart cast not possible is UiState.Success -> {} else -> {} }
// CORRECT - use let or handle null explicitly state?.let { nonNull -> when (nonNull) { is UiState.Loading -> LoadingIndicator() is UiState.Success -> OrderDetails(nonNull.data) is UiState.Error -> ErrorMessage(nonNull.message) is UiState.Empty -> EmptyState() } }
// Or include null as a case when (state) { null -> LoadingIndicator() // Initial state is UiState.Loading -> LoadingIndicator() is UiState.Success -> OrderDetails(state.data) is UiState.Error -> ErrorMessage(state.message) is UiState.Empty -> EmptyState() } ```
Prevention
- Never use
elsebranch with sealed class when expressions - Add sealed class hierarchy changes as a checklist item in code reviews
- Use sealed interface instead of sealed class for cross-module hierarchies
- Write unit tests that verify all sealed class branches are exercised
- Use compiler errors as TODO list when adding new sealed class subtypes
- Document each sealed class with comments listing all known subtypes