Skip to content

Fix: Kotlin Sealed Class Not Working — when Expression Not Exhaustive or Subclass Not Found

FixDevs ·

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:

  • when without else only exhaustive on expressionswhen used as a statement (not assigning a value) doesn’t require exhaustiveness. Only when used 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 interface or 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 file

Fix 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles