How do I use sealed classes for exhaustive type-safe hierarchies in Kotlin?

In Kotlin, sealed classes (and sealed interfaces) let you model a closed, type-safe hierarchy: a fixed set of known subtypes. This is especially useful for things like UI state, results, commands, events, and domain-specific alternatives.

Basic idea

A sealed class restricts which classes can inherit from it.

sealed class Result

data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
data object Loading : Result()

Now Result can only be one of the known subclasses: Success, Error, or Loading.

Exhaustive when

The main benefit is that Kotlin can check whether a when expression handles every possible subtype.

fun render(result: Result): String {
    return when (result) {
        is Success -> "Data: ${result.data}"
        is Error -> "Error: ${result.message}"
        Loading -> "Loading..."
    }
}

Because Result is sealed, the compiler knows all possible cases. You do not need an else branch if all cases are covered.

If you add another subtype:

data object Empty : Result()

Then this when becomes incomplete, and the compiler will require you to handle Empty.

Prefer data object for singleton states

For sealed hierarchies with singleton cases, use data object:

sealed class UiState {
    data object Loading : UiState()
    data object Empty : UiState()
    data class Success(val items: List<String>) : UiState()
    data class Error(val cause: Throwable) : UiState()
}

Usage:

fun message(state: UiState): String =
    when (state) {
        UiState.Loading -> "Loading"
        UiState.Empty -> "No items"
        is UiState.Success -> "Loaded ${state.items.size} items"
        is UiState.Error -> "Failed: ${state.cause.message}"
    }

Sealed classes vs enums

Use an enum class when every case is a simple constant:

enum class Direction {
    NORTH, SOUTH, EAST, WEST
}

Use a sealed class when cases may carry different data:

sealed class PaymentStatus {
    data object Pending : PaymentStatus()
    data class Paid(val receiptId: String) : PaymentStatus()
    data class Failed(val reason: String) : PaymentStatus()
}

Sealed interfaces

A sealed interface is useful when subclasses may also extend another class, or when you want multiple sealed abstractions.

sealed interface NetworkState

data object Offline : NetworkState
data object Connecting : NetworkState
data class Online(val bandwidthMbps: Int) : NetworkState

Usage:

fun describe(state: NetworkState): String =
    when (state) {
        Offline -> "Offline"
        Connecting -> "Connecting"
        is Online -> "Online at ${state.bandwidthMbps} Mbps"
    }

Nesting subclasses inside the sealed type

A common style is to define all cases inside the sealed class for readability:

sealed class AuthResult {
    data class Success(val userId: String) : AuthResult()
    data object InvalidCredentials : AuthResult()
    data object NetworkFailure : AuthResult()
}

Then use it like this:

fun handle(result: AuthResult): String =
    when (result) {
        is AuthResult.Success -> "Welcome ${result.userId}"
        AuthResult.InvalidCredentials -> "Invalid username or password"
        AuthResult.NetworkFailure -> "Please check your connection"
    }

Generic sealed result type

A common pattern is a generic result wrapper:

sealed class AppResult<out T> {
    data class Success<T>(val value: T) : AppResult<T>()
    data class Failure(val error: Throwable) : AppResult<Nothing>()
    data object Loading : AppResult<Nothing>()
}

Example usage:

fun display(result: AppResult<String>): String =
    when (result) {
        is AppResult.Success -> "Value: ${result.value}"
        is AppResult.Failure -> "Error: ${result.error.message}"
        AppResult.Loading -> "Loading..."
    }

The out T makes AppResult covariant, so AppResult<String> can be used where AppResult<Any> is expected.

Rules to remember

In modern Kotlin:

  • Direct subclasses of a sealed class/interface must be in the same package.
  • They must be declared in the same module.
  • Sealed subclasses can be top-level or nested.
  • A sealed class is abstract by default.
  • Sealed classes cannot be instantiated directly.

Example:

sealed class Command

data class CreateUser(val name: String) : Command()
data class DeleteUser(val id: Long) : Command()
data object Sync : Command()

Exhaustive when as an expression

For exhaustiveness checking, prefer using when as an expression:

val text = when (state) {
    UiState.Loading -> "Loading"
    UiState.Empty -> "Empty"
    is UiState.Success -> "Success"
    is UiState.Error -> "Error"
}

If you use when only as a statement, exhaustiveness checking may be less useful depending on context and Kotlin version/settings.

Practical example: UI state

sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data class Loaded(
        val name: String,
        val email: String
    ) : ProfileUiState
    data class Failed(val message: String) : ProfileUiState
}

fun renderProfile(state: ProfileUiState): String =
    when (state) {
        ProfileUiState.Loading -> "Loading profile..."
        is ProfileUiState.Loaded -> """
            Name: ${state.name}
            Email: ${state.email}
        """.trimIndent()
        is ProfileUiState.Failed -> "Could not load profile: ${state.message}"
    }

Best practices

  • Use sealed types to represent a closed set of alternatives.
  • Use data class for cases with data.
  • Use data object for singleton cases.
  • Avoid unnecessary else branches in when; let the compiler check exhaustiveness.
  • Keep sealed hierarchies small and meaningful.
  • Prefer sealed interfaces when you need more flexible inheritance.
  • Prefer enums for simple constant-only sets.

In short: sealed classes give you algebraic data type-style modeling in Kotlin, with compiler-checked exhaustive handling through when.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.