Fix: Kotlin Sealed Class Not Working — when Expression Not Exhaustive or Subclass Not Found
Quick Answer
How to fix Kotlin sealed class issues — when exhaustiveness, sealed interface vs class, subclass visibility, Result pattern, and sealed classes across modules.
The Problem
A when expression on a sealed class doesn’t get exhaustiveness checking:
sealed class Result<out T>
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String) : Result<Nothing>()
fun handleResult(result: Result<User>) {
when (result) {
is Success -> println(result.data)
// Compiler doesn't warn about missing Error branch
}
}Or a sealed class subclass isn’t visible in another file:
// models/NetworkState.kt
sealed class NetworkState {
object Loading : NetworkState()
data class Success(val data: String) : NetworkState()
data class Failure(val error: Throwable) : NetworkState()
}
// ui/HomeViewModel.kt
fun processState(state: NetworkState) {
when (state) {
is NetworkState.Loading -> { }
// Error: 'Success' is not a subtype of 'NetworkState'
}
}Or adding a new subclass silently breaks existing when expressions without compiler warnings.
Why This Happens
Kotlin sealed classes have specific rules that are easy to violate:
whenwithoutelseonly exhaustive on expressions —whenused as a statement (not assigning a value) doesn’t require exhaustiveness. Onlywhenused as an expression (assigned or returned) triggers the “must be exhaustive” error.- Subclasses must be in the same package (sealed class) or same file (Kotlin 1.1+) — for
sealed class, all direct subclasses must be in the same package and the same compilation unit.sealed interface(Kotlin 1.5+) is more flexible. - Sealed class in another module — subclasses can be in the same module but not in different modules (Gradle subprojects). Use
sealed interfaceor a common module for cross-module sealed types.
Fix 1: Force Exhaustiveness with when as Expression
Make when an expression by assigning or returning it:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>()
}
// WRONG — when as statement, no exhaustiveness check
fun handleState(state: UiState<User>) {
when (state) {
is UiState.Loading -> showLoader()
// Missing Error branch — no compiler warning!
}
}
// CORRECT — when as expression (return value forces all branches)
fun handleState(state: UiState<User>) {
val action: () -> Unit = when (state) {
is UiState.Loading -> { { showLoader() } }
is UiState.Success -> { { showUser(state.data) } }
is UiState.Error -> { { showError(state.message) } }
// Now missing a branch causes: 'when' expression must be exhaustive
}
action()
}
// Alternative — use the exhaustive extension property
val <T> T.exhaustive: T get() = this
fun handleState(state: UiState<User>) {
when (state) {
is UiState.Loading -> showLoader()
is UiState.Success -> showUser(state.data)
is UiState.Error -> showError(state.message)
}.exhaustive // Forces expression form — compiler checks all branches
}Fix 2: Use sealed interface for Cross-File Hierarchies
sealed interface (Kotlin 1.5+) allows subclasses anywhere in the same module:
// Kotlin 1.5+ — sealed interface (preferred for flexibility)
sealed interface NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>
data class HttpError(val code: Int, val message: String) : NetworkResult<Nothing>
data class NetworkError(val cause: IOException) : NetworkResult<Nothing>
object Loading : NetworkResult<Nothing>
}
// Subclasses can be in different files within the same module
// models/SpecialResult.kt
data class CachedResult<T>(val data: T, val timestamp: Long) : NetworkResult<T>
// This is valid with sealed interface (not allowed with sealed class in another file)// sealed class — subclasses must be in the same file (Kotlin 1.1+)
// or same package (older versions)
// NetworkState.kt
sealed class NetworkState {
object Loading : NetworkState()
data class Success(val data: String) : NetworkState()
// All subclasses must be defined in this file
}
// In another file — this does NOT work with sealed class
// class OfflineState : NetworkState() // Error: Cannot subclass sealed class from another fileFix 3: Model UI State with Sealed Classes
A complete UI state pattern using sealed classes:
// Common pattern for ViewModel state
sealed class ViewState<out T> {
object Idle : ViewState<Nothing>()
object Loading : ViewState<Nothing>()
data class Success<T>(val data: T) : ViewState<T>()
data class Error(
val message: String,
val retryable: Boolean = true,
val cause: Throwable? = null,
) : ViewState<Nothing>()
}
// Extension functions for ergonomic access
fun <T> ViewState<T>.onSuccess(block: (T) -> Unit): ViewState<T> {
if (this is ViewState.Success) block(data)
return this
}
fun <T> ViewState<T>.onError(block: (String) -> Unit): ViewState<T> {
if (this is ViewState.Error) block(message)
return this
}
fun <T> ViewState<T>.onLoading(block: () -> Unit): ViewState<T> {
if (this is ViewState.Loading) block()
return this
}
// ViewModel
class UserViewModel(private val repo: UserRepository) : ViewModel() {
private val _state = MutableStateFlow<ViewState<User>>(ViewState.Idle)
val state: StateFlow<ViewState<User>> = _state.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_state.value = ViewState.Loading
_state.value = try {
ViewState.Success(repo.getUser(id))
} catch (e: Exception) {
ViewState.Error(e.message ?: "Unknown error", cause = e)
}
}
}
}
// Fragment/Activity
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state ->
when (state) {
is ViewState.Idle -> Unit
is ViewState.Loading -> showProgress()
is ViewState.Success -> showUser(state.data)
is ViewState.Error -> showError(state.message, state.retryable)
}
}
}Fix 4: Sealed Classes for Domain Events
Model domain events and commands as sealed hierarchies:
// Domain events
sealed interface UserEvent {
data class Registered(val userId: String, val email: String) : UserEvent
data class ProfileUpdated(val userId: String, val changes: Map<String, Any>) : UserEvent
data class PasswordChanged(val userId: String) : UserEvent
data class AccountDeleted(val userId: String) : UserEvent
}
// Command pattern
sealed interface OrderCommand {
data class PlaceOrder(val items: List<OrderItem>, val userId: String) : OrderCommand
data class CancelOrder(val orderId: String, val reason: String) : OrderCommand
data class UpdateAddress(val orderId: String, val address: Address) : OrderCommand
}
fun processCommand(command: OrderCommand): OrderResult {
return when (command) {
is OrderCommand.PlaceOrder -> placeOrder(command.items, command.userId)
is OrderCommand.CancelOrder -> cancelOrder(command.orderId, command.reason)
is OrderCommand.UpdateAddress -> updateAddress(command.orderId, command.address)
// Compiler enforces all cases are handled
}
}Fix 5: Nested Sealed Classes
Organize complex state hierarchies with nested sealing:
sealed class AppNavigation {
sealed class Auth : AppNavigation() {
object Login : Auth()
object Register : Auth()
data class ForgotPassword(val email: String = "") : Auth()
}
sealed class Main : AppNavigation() {
object Home : Main()
object Profile : Main()
data class ProductDetail(val productId: String) : Main()
data class OrderHistory(val userId: String) : Main()
}
object Onboarding : AppNavigation()
}
fun navigate(destination: AppNavigation) {
when (destination) {
is AppNavigation.Auth -> when (destination) {
AppNavigation.Auth.Login -> navController.navigate("login")
AppNavigation.Auth.Register -> navController.navigate("register")
is AppNavigation.Auth.ForgotPassword -> navController.navigate("forgot/${destination.email}")
}
is AppNavigation.Main -> when (destination) {
AppNavigation.Main.Home -> navController.navigate("home")
AppNavigation.Main.Profile -> navController.navigate("profile")
is AppNavigation.Main.ProductDetail -> navController.navigate("product/${destination.productId}")
is AppNavigation.Main.OrderHistory -> navController.navigate("orders/${destination.userId}")
}
AppNavigation.Onboarding -> navController.navigate("onboarding")
}
}Fix 6: Sealed Classes with Generics
// Result type (similar to Kotlin's built-in Result)
sealed class Either<out L, out R> {
data class Left<L>(val value: L) : Either<L, Nothing>()
data class Right<R>(val value: R) : Either<Nothing, R>()
}
// Convenient construction
fun <L> left(value: L): Either<L, Nothing> = Either.Left(value)
fun <R> right(value: R): Either<Nothing, R> = Either.Right(value)
// Map the right side
fun <L, R, T> Either<L, R>.map(transform: (R) -> T): Either<L, T> = when (this) {
is Either.Left -> this
is Either.Right -> Either.Right(transform(value))
}
// Fold into a single value
fun <L, R, T> Either<L, R>.fold(onLeft: (L) -> T, onRight: (R) -> T): T = when (this) {
is Either.Left -> onLeft(value)
is Either.Right -> onRight(value)
}
// Usage
fun divideOrError(a: Int, b: Int): Either<String, Int> {
return if (b == 0) left("Division by zero") else right(a / b)
}
val result = divideOrError(10, 2)
.map { it * 100 }
.fold(
onLeft = { error -> "Error: $error" },
onRight = { value -> "Result: $value" }
)
// "Result: 500"Still Not Working?
else branch hides missing cases — adding else -> to a when over a sealed class suppresses exhaustiveness checking. Remove else when handling sealed classes to let the compiler warn you about unhandled subclasses:
// WRONG — else hides future subclass additions
when (state) {
is UiState.Loading -> showLoader()
is UiState.Success -> showData(state.data)
else -> { } // Future subclasses silently go here
}
// CORRECT — no else, compiler warns if you add a new subclass
when (state) {
is UiState.Loading -> showLoader()
is UiState.Success -> showData(state.data)
is UiState.Error -> showError(state.message)
}Sealed class in a shared library module — subclasses of a sealed class cannot be defined outside the module where the sealed class is declared. If you need extensible sealed hierarchies across modules, use sealed interface (Kotlin 1.5+) or an abstract class with limited visibility.
Smart cast fails after null check — if you check state != null but then use when (state), Kotlin may not smart-cast inside when. Store the result: val s = state ?: return; when (s) { ... }.
For related Kotlin issues, see Fix: Kotlin Coroutine Scope Cancelled and Fix: Kotlin Coroutine Not Executing.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Kotlin Flow Not Working — Not Collecting, StateFlow Not Updating, or Flow Cancelled Unexpectedly
How to fix Kotlin Flow issues — cold vs hot flows, collectLatest vs collect, StateFlow and SharedFlow setup, lifecycle-aware collection in Android, and common Flow cancellation problems.
Fix: Kotlin Coroutine Not Executing — launch{} or async{} Blocks Not Running
How to fix Kotlin coroutines not executing — CoroutineScope setup, dispatcher selection, structured concurrency, cancellation handling, blocking vs suspending calls, and exception propagation.
Fix: Kotlin Coroutine Scope Cancelled — JobCancellationException or Coroutine Not Running
How to fix Kotlin coroutine cancellation issues — scope lifecycle, SupervisorJob, CancellationException handling, structured concurrency, viewModelScope, and cooperative cancellation.
Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.