Skip to content

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

FixDevs · (Updated: )

Part of:  Java & JVM Errors

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.

Sealed types in Kotlin are not a single feature — they grew across five major releases. Kotlin 1.0 introduced sealed class with nested-only subclasses. 1.1 allowed same-file subclasses. 1.5 added sealed interface and same-module subclasses. 1.7 made when-on-sealed exhaustiveness a compiler error for expression form. 1.9 added data object. 2.0 shipped K2 with rewritten smart-cast analysis. A snippet that compiles on Kotlin 1.4 may produce different warnings on 1.7 and different errors on 2.0 — and a multi-module project pinning different kotlinVersion values can show contradictory diagnostics on identical sources.

The second confusion is the difference between a when statement and a when expression. A statement discards its value (called for side effects); an expression is assigned, returned, or otherwise consumed. Exhaustiveness checks have historically only fired on expression form, which is why teams use an .exhaustive extension trick that forces statement form into expression form. Kotlin 1.7 partially closes this gap by warning on non-exhaustive when-on-sealed even as a statement.

The third issue is module boundaries. A sealed class or sealed interface in Kotlin 1.5+ can have subclasses anywhere in the same module (Gradle subproject), but not in another module. This is enforced at the bytecode level via the PermittedSubclasses attribute. If you publish a sealed type in a library and expect downstream consumers to extend it, that is impossible by design — sealed means “the producer enumerates all variants.” Use an abstract class with @Suppress markers if you need cross-module extensibility.

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"

Version History: Sealed Types Across Kotlin Releases

Sealed types have evolved across nearly every major Kotlin release. Knowing what your kotlinVersion permits saves a lot of “but this works in the other module” debugging.

Kotlin 1.0 (Feb 2016) — sealed class introduced. Only nested subclasses — every subtype had to live inside the sealed class curly braces.

Kotlin 1.1 (Mar 2017) — same-file subclasses. Subclasses moved out of the curlies and could live anywhere in the same file. Most older tutorials use this form.

Kotlin 1.5 (May 2021) — sealed interface and same-module subclasses. The single largest change. sealed interface lets subclasses live anywhere in the same module, and sealed class was extended with the same rule. Multiple sealed interface inheritance is allowed, enabling intersection-style modeling impossible with sealed class. This is when sealed types became practical for real-world domain modeling.

Kotlin 1.6 - 1.7 (2021 - 2022) — when exhaustiveness tightening. 1.6 added a warning for non-exhaustive when statements on sealed types. 1.7 promoted it to an error when when is used as an expression.

Kotlin 1.8 (Jan 2023) — Java 17 sealed interop. Pairing Kotlin 1.8 with JVM target 17 enables cross-language exhaustive matching with Java’s own sealed keyword.

Kotlin 1.9 (Jul 2023) — data object. Sealed hierarchies often have singleton members. data object gives them generated toString(), equals(), and hashCode() matching the data class pattern.

Kotlin 2.0 (May 2024) — K2 compiler stable. Rewrites smart-cast analysis and exhaustiveness checking. Many “compiler thinks this is non-exhaustive but it clearly is” edge cases were fixed. Diagnostics now explicitly list which sealed members are uncovered. Large multi-module Android projects saw 30-40% faster builds on K2.

Kotlin 2.1 (Nov 2024) — non-exhaustive when on sealed is a default error. What was a warning in 1.6 became the default error in 2.1. New projects no longer need a custom Detekt rule.

Practical migration rules. If your code uses sealed class with subclasses in another file (same package) and won’t compile, you are on Kotlin <1.5 or a build that selectively disables the language feature — upgrade or split the file. If exhaustiveness checks are missing on a when statement, you are on <1.7 or the when is being used as a statement; assign the result to Unit or use the .exhaustive trick to force expression form. If you see Cannot inherit from a sealed class from another module, your only options are converting to sealed interface (no change in cross-module limitation, but allows multi-inheritance) or moving the type into a shared module. There is no flag to disable this — it is a language guarantee.

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) { ... }.

Java consumers see a sealed Kotlin type as non-sealed — until Kotlin 1.5, the sealed marker was only enforced by the Kotlin compiler. From Kotlin 1.5 onwards, the bytecode includes the PermittedSubclasses attribute when targeting JVM 17+, but only if you set jvmTarget = "17" (or higher). On JVM 8 targets — still very common in Android — Java code can technically extend a Kotlin sealed type because the runtime doesn’t enforce it. Use @JvmName and internal visibility to make the API harder to misuse from Java, or migrate the JVM target to 17 if your runtime allows.

Sealed type works in unit tests but fails in production build — almost always a multi-module Gradle setup where the unit test sits in the same module as the sealed type but the production code imports it across a module boundary. The test sees same-module-inheritance and compiles; production fails because the production usage is fine, but a test fixture in the production module’s androidTest source set is now in a different “compilation unit.” Move the sealed type to a common library module or convert it to an interface with package-private constructors.

when is exhaustive in the IDE but not in the Gradle build — the IDE uses its own analyzer which may be more lenient (or stricter) than the command-line kotlinc. The fix is to make sure both are on the same Kotlin version. Check kotlin.compiler.version in gradle.properties, the kotlin("multiplatform") version "..." plugin block, and the IDE’s Kotlin plugin version — all three should match.

For related Kotlin issues, see Fix: Kotlin Coroutine Scope Cancelled, Fix: Kotlin Coroutine Not Executing, and Fix: Kotlin Flow Not Working. For the Java analogue of sealed types, see Fix: Java Record Not Working — records and sealed interfaces are the JDK 17+ equivalent of Kotlin’s sealed hierarchies.

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