Sealed Types, Enums, and Exhaustive Control Flow in Kotlin

If you’ve ever dealt with algebraic data types in other languages or wanted to model closed hierarchies without the fear of missing cases, this is for you. We’ll cover everything from sealed classes versus interfaces to designing closed-world models, with full code examples to illustrate each point. By the end, you’ll have a toolkit for writing more maintainable, compiler-verified code.

Let’s jump in.

Sealed Classes vs Sealed Interfaces

Sealed types in Kotlin are a powerful way to represent restricted hierarchies. They’re like enums on steroids, allowing you to define a fixed set of subtypes that the compiler knows about exhaustively. This is crucial for modeling sum types (e.g., “this or that, but nothing else”) in a type-safe manner.

First, sealed classes. Introduced in Kotlin 1.0, a sealed class is abstract and can’t be instantiated directly. Its subclasses must be defined in the same file (or, since Kotlin 1.5, in the same package and module). This restriction ensures the compiler can verify all possible subtypes at compile time.

Here’s a classic example: modeling API responses.

Kotlin

sealed class ApiResponse

data class Success(val data: String) : ApiResponse()

data class Error(val message: String) : ApiResponse()

class Loading : ApiResponse()  // Non-data subclass for state

// Usage
fun handleResponse(response: ApiResponse) {
    when (response) {
        is Success -> println("Data: ${response.data}")
        is Error -> println("Error: ${response.message}")
        is Loading -> println("Loading...")
    }
}

In this setup, the when expression is exhaustive—no else needed because the compiler knows these are the only possibilities. If you add a new subclass like NetworkError, the compiler will flag all unhandled when branches, preventing runtime surprises.

Now, sealed interfaces (Kotlin 1.5+). They work similarly but allow for more flexibility. Interfaces don’t require constructors, and subtypes can be classes, objects, or even other interfaces. Importantly, sealed interfaces support multiple inheritance in hierarchies, which sealed classes don’t (since classes are single-inheritance).

Consider modeling shapes in a graphics app:

Kotlin

sealed interface Shape

class Circle(val radius: Double) : Shape

class Rectangle(val width: Double, val height: Double) : Shape

object Empty : Shape  // Singleton for no shape

// Usage with multiple inheritance if needed
interface Drawable
class Square(val side: Double) : Shape, Drawable  // Possible with sealed interface

fun draw(shape: Shape) {
    when (shape) {
        is Circle -> println("Drawing circle with radius ${shape.radius}")
        is Rectangle -> println("Drawing rectangle ${shape.width}x${shape.height}")
        is Empty -> println("Nothing to draw")
        // Compiler ensures exhaustiveness
    }
}

Key differences:

  • Sealed Classes: Better for data-heavy hierarchies (with constructors). Subclasses must extend directly.
  • Sealed Interfaces: Ideal for behavior-focused or mixed hierarchies. Allow anonymous objects and more extensibility within the sealed bounds.

As a mid-level dev, choose sealed classes for simple ADTs (Algebraic Data Types) and interfaces when you need to mix in other traits. Both enforce closed worlds, but interfaces reduce boilerplate in complex models.

Exhaustiveness Checking Rules

Exhaustiveness is Kotlin’s killer feature for sealed types and enums—it ensures every possible case is handled, catching errors at compile time rather than runtime.

The rules are straightforward but have nuances. For a when expression on a sealed type or enum, the compiler checks if all subtypes/values are covered. If not, it demands an else or flags an error if used as an expression (where when must return a value).

But it’s not just about direct subtypes. Nested sealeds complicate things. Consider a hierarchical response:

Kotlin

sealed class ApiResponse

sealed class Success : ApiResponse()
data class UserData(val name: String) : Success()
data class PostData(val content: String) : Success()

sealed class Failure : ApiResponse()
data class NetworkError(val code: Int) : Failure()
data class ServerError(val message: String) : Failure()

// Usage
fun process(response: ApiResponse): String = when (response) {
    is UserData -> "User: ${response.name}"
    is PostData -> "Post: ${response.content}"
    is NetworkError -> "Error code: ${response.code}"
    is ServerError -> "Server: ${response.message}"
    // Exhaustive: All leaves covered, even though Success/Failure are intermediates
}

Here, you don’t need to handle Success or Failure directly—the compiler traverses the hierarchy and verifies leaf coverage.

Rules in detail:

  • Direct Subtypes: Must cover all if no nesting.
  • Nested Sealeds: Recursively checks all terminal subtypes (non-sealed leaves).
  • Objects vs Classes: Objects (singletons) count as one case; data classes as types.
  • Nullability: If the type is nullable (e.g., ApiResponse?), you must handle null explicitly.
  • Smart Casts: In when with is, properties are smart-cast, but exhaustiveness ignores that—it’s about types.

Edge case: If a subtype is in another file (pre-1.5), it’s not sealed—exhaustiveness breaks. Always keep them co-located.

For middle devs: Always prefer exhaustive when over if-else chains for sealed types. It future-proofs your code against hierarchy changes.

when Exhaustiveness and Compiler Guarantees

Building on exhaustiveness, let’s zoom in on when expressions. In Kotlin, when can be exhaustive for booleans, enums, sealeds, and even strings/numbers in limited cases, but sealeds shine here.

Compiler guarantees: If exhaustive, no else is needed, and the expression infers a type safely. This prevents “impossible” branches at runtime.

Example with error handling:

Kotlin

sealed class Result<out T>
data class Ok<T>(val value: T) : Result<T>()
data class Err(val exception: Exception) : Result<Nothing>()

fun compute(): Result<Int> = Ok(42)  // Or Err(Exception("Boom"))

fun main() {
    val result = compute()
    val value: Int = when (result) {  // Type infers to Int, no nullability
        is Ok -> result.value
        is Err -> throw result.exception  // Handles error path
    }
    println(value)  // Guaranteed non-error
}

Here, the compiler guarantees that value is Int because all paths are covered, and Err throws (or could return a default, but exhaustiveness ensures coverage).

More guarantees:

  • No Runtime Checks: Unlike Java’s switch, no hidden defaults.
  • Refactoring Safety: Add a subtype? Compiler errors everywhere it’s unhandled.
  • Expression vs Statement: As expression, must be exhaustive; as statement, optional but recommended.

Pitfall for mids: Overusing else defeats the purpose. Train yourself to remove it and let the compiler guide you.

Advanced: Combine with Elvis for nullables:

Kotlin

val nullableResponse: ApiResponse? = null
val handled = when (nullableResponse) {
    is Success -> "Success"
    is Error -> "Error"
    null -> "No response"
    // Exhaustive including null
}

This leverages Kotlin’s flow typing for safety.

(Word count so far: ~1250)

Evolution of Sealed Hierarchies

Sealed hierarchies aren’t static—they evolve. Kotlin’s design supports adding subtypes without breaking existing code, but with compiler nudges for updates.

Start simple, then expand. Suppose we have a basic UI state:

Kotlin

sealed class UiState
object Idle : UiState()
data class Loaded(val data: List<String>) : UiState()
data class Error(val msg: String) : UiState()

Later, add Loading:

Kotlin

data class Loading(val progress: Int) : UiState()  // New subtype

Now, all when on UiState will error until you add the branch. This is intentional—forces updates.

Evolution tips:

  • Backward Compat: Adding subtypes is safe for consumers if they use else, but for exhaustiveness, it’s a breaking change (good for internal libs).
  • Versioning: In libraries, use sealed interfaces for more flexibility; subclasses can be added in submodules (Kotlin 1.5+).
  • Deprecation: Deprecate old subtypes gradually.

Example evolution in a library:

Initial version:

Kotlin

// Library v1
sealed interface Event
object Click : Event
object Hover : Event

Consumer:

Kotlin

fun handle(event: Event) = when (event) {
    Click -> "Clicked"
    Hover -> "Hovered"
}

v2 adds Drag:

Kotlin

object Drag : Event

Consumer’s code breaks at compile time—must update. Ideal for catching oversights.

For middle devs: Design with evolution in mind. Use sealeds for internal domains, open classes for extensible APIs.

Enums: Semantics and Runtime Costs

Enums in Kotlin are classes under the hood, offering more than Java’s (e.g., properties, methods). They’re sealed implicitly, enabling exhaustiveness.

Semantics: Each enum constant is a singleton subclass. You can add state/behavior.

Basic enum:

Kotlin

enum class Color {
    RED, GREEN, BLUE
}

// Exhaustive when
fun describe(color: Color) = when (color) {
    RED -> "Warm"
    GREEN -> "Neutral"
    BLUE -> "Cool"
}

Advanced with properties:

Kotlin

enum class Planet(val mass: Double, val radius: Double) {
    EARTH(5.97e24, 6371.0),
    MARS(6.42e23, 3389.5);

    fun surfaceGravity(): Double = 6.67430e-11 * mass / (radius * radius * 1e6)
}

fun main() {
    println(Planet.EARTH.surfaceGravity())  // ~9.81
    val planet: Planet = Planet.MARS
    when (planet) {  // Exhaustive
        Planet.EARTH -> println("Home")
        Planet.MARS -> println("Red planet")
    }
}

Runtime costs: Enums are loaded eagerly (static initialization). For large enums, this can impact startup. Each constant is an object, so memory footprint grows linearly.

Costs:

  • Memory: ~50-100 bytes per constant (plus fields).
  • Performance: Enum lookups are fast (array-based), but avoid in hot loops if possible.
  • vs Sealeds: Enums for fixed, simple values; sealeds for complex data/behavior.

Pitfall: Overusing enums for states with data—prefer sealeds to avoid bloating.

For mids: Use enums for flags/configs, sealeds for results/states.

Modeling Finite State Spaces

Finite state spaces are perfect for sealeds/enums: UI screens, API states, game modes. They limit possibilities, enabling exhaustive handling.

Example: FSM for a door:

Kotlin

sealed class DoorState

object Open : DoorState()
object Closed : DoorState()
data class Locked(val key: String) : DoorState()

fun transition(current: DoorState, action: String): DoorState = when (current) {
    Open -> when (action) {
        "close" -> Closed
        else -> Open  // But prefer exhaustive actions too
    }
    Closed -> when (action) {
        "open" -> Open
        "lock" -> Locked("secret")
        else -> Closed
    }
    is Locked -> when (action) {
        "unlock" -> if (action == current.key) Closed else current
        else -> current
    }
}

fun main() {
    var state: DoorState = Closed
    state = transition(state, "open")  // Open
    state = transition(state, "close")  // Closed
    state = transition(state, "lock")  // Locked
    // Compiler ensures all states handled in transition
}

This models a finite automaton. Exhaustiveness ensures no undefined transitions.

Tips:

  • Nest when for complex logic.
  • Use enums for actions if finite.
  • For larger spaces, consider state machines libs, but sealeds handle basics well.

Benefits: Reduces bugs in concurrent systems (e.g., coroutines) by forcing case coverage.

(Word count so far: ~2300)

Binary Compatibility Pitfalls

Binary compatibility (BC) is key for libraries. Sealeds can break BC if not careful.

Pitfalls:

  • Adding Subtypes: Breaks exhaustive when in consumers—source compat ok, but binary? If consumer compiled against old version, new subtype causes runtime “no branch” if no else.
  • Removing Subtypes: Breaks if consumers handle it.
  • Changing Modifiers: Making a subtype non-data affects equals/hashCode.

Example:

Library v1:

Kotlin

sealed class Status
object Good : Status()
object Bad : Status()

Consumer compiles against v1, uses exhaustive when.

v2 adds Ugly:

Kotlin

object Ugly : Status()

If consumer runs with v2 JAR without recompiling, a Ugly instance hits no branch—crash if expression.

Fix: Add else in public APIs, or use @JvmStatic carefully. For BC, prefer open classes or provide adapters.

Kotlin’s KAPT/KSP help, but test BC with tools like Binary Compatibility Validator.

For mids: In libs, document sealed evolutions; use semantic versioning.

Designing Closed-world Models

Closed-world models assume a fixed set of possibilities—ideal for sealeds/enums. Think domains like payment statuses, user roles.

Design principles:

  • Identify Finiteness: If “only these options,” use sealed.
  • Layer Hierarchies: Top-level sealed, nested for categories.
  • Combine with Generics: For type-safe results.
  • Test Exhaustiveness: Write tests that fail on new subtypes (via reflection, but sparingly).

Full example: Payment system.

Kotlin

sealed interface PaymentResult<out T>

sealed interface Success<T> : PaymentResult<T>
data class Processed<T>(val receipt: T) : Success<T>()
object Refunded : Success<Nothing>()

sealed interface Failure : PaymentResult<Nothing>
data class Declined(val reason: String) : Failure()
object Timeout : Failure()

fun processPayment(): PaymentResult<String> = Processed("Receipt123")

fun handle(result: PaymentResult<*>) {
    when (result) {
        is Processed -> println("Success: ${result.receipt}")
        Refunded -> println("Refunded")
        is Declined -> println("Declined: ${result.reason}")
        Timeout -> println("Timeout")
        // Exhaustive across hierarchies
    }
}

This closed model ensures safe handling. Extend by adding to failures/successes, compiler enforces updates.

In practice: Use for Redux-like states in apps, or API contracts.

Wrapping up: Sealed types and enums transform error-prone code into compiler-verified masterpieces. Experiment in your projects—start small, like replacing if-chains with exhaustive when. Questions? Drop a comment!

Leave a Reply

Your email address will not be published. Required fields are marked *