skip to content

How do exhaustiveness and smart-casting behave for a `when` over a nullable sealed type, and over a sealed hierarchy that itself nests sealed subtypes?

level: seniorimportance: should knowfreq 35%

answer

  1. Nullable sealed → need a null branch (or pre-handle)
  2. Match intermediate sealed parent in one arm
  3. Parent match → new leaf doesn't break it
  4. Leaf enumeration → new leaf breaks it (intentional)
  5. Generics erased → match `is Left<*>`

basics

~20 s

If the value can be null you must add a null branch (or handle it before) to stay exhaustive. For nested sealed types you can either list each leaf or match the intermediate sealed parent in one branch — the compiler tracks both.

solid answer

~40 s

For a **nullable** sealed subject (`T?`), exhaustiveness requires handling `null` — either a dedicated `null ->` arm, a `?.let`/early return, or matching it within a branch — otherwise the `when` expression won't compile. After a non-null match the subject smart-casts to the non-null subtype. For **nested** sealed hierarchies (a sealed subtype that is itself sealed), the compiler treats exhaustiveness recursively: you may cover an intermediate sealed type in a single `is Parent ->` branch (exhaustive at that level) or drill into each leaf. If you match the parent, the subject smart-casts to that parent type; to reach leaf-specific data you nest another `when`. Adding a new leaf under a nested sealed type breaks only the inner `when` that enumerated leaves, not one that matched the parent — a useful granularity lever.

code

kotlin · 14 lines
kotlin
sealed interface Event
sealed interface UserEvent : Event
data class Login(val id: String) : UserEvent
data class Logout(val id: String) : UserEvent
data class SystemTick(val ms: Long) : Event

fun summarize(e: Event?): String = when (e) {
    null -> "idle"
    is UserEvent -> when (e) {     // e smart-cast to UserEvent
        is Login  -> "in:${e.id}"
        is Logout -> "out:${e.id}"
    }
    is SystemTick -> "tick:${e.ms}"
}

go deeper

for a junior

Knows a nullable sealed when needs a null branch.

for a middle

Handles nullable cases and can enumerate leaves of a nested hierarchy.

for a senior

Chooses parent-match vs leaf-enumeration deliberately for desired compile-break granularity and nests smart-casts.

for a principal

Designs hierarchy depth and where exhaustiveness should bite to control change-blast-radius across modules, accounting for generics/erasure.

## Nullable sealed subjects A sealed type can still be nullable: `Result?`. Because `null` is a valid value, the compiler will **not** consider the `when` exhaustive until `null` is handled. ```kotlin sealed interface Result data class Ok(val v: Int) : Result data class Err(val m: String) : Result fun show(r: Result?): String = when (r) { null -> "none" // required for exhaustiveness is Ok -> "ok ${r.v}" // r smart-cast to non-null Ok is Err -> "err ${r.m}" } ``` Alternatives: handle null up front (`r ?: return ...`) so the `when` subject is non-null, or use `?.let { when(it) { ... } }`. ## Nested sealed hierarchies Sealed subtypes may themselves be sealed, forming a tree: ```kotlin sealed interface Event sealed interface UserEvent : Event data class Login(val id: String) : UserEvent data class Logout(val id: String) : UserEvent data class SystemTick(val ms: Long) : Event ``` ### Option A — match the intermediate parent ```kotlin fun handle(e: Event) = when (e) { is UserEvent -> audit(e) // e smart-cast to UserEvent is SystemTick -> tick(e.ms) } ``` Here the inner leaves (`Login`/`Logout`) are covered collectively. Adding `data class Signup(...) : UserEvent` does **not** break this `when`. ### Option B — enumerate leaves ```kotlin fun handle(e: Event) = when (e) { is Login -> ... is Logout -> ... is SystemTick -> ... } ``` Adding `Signup` **breaks** this `when` — forcing you to handle it. ## Choosing the granularity - Match the **parent** when callers genuinely treat the whole sub-family uniformly (fewer churn points). - Enumerate **leaves** when each leaf needs distinct handling and you *want* new leaves to surface as compile errors. ## Generics interplay With a generic sealed type (`sealed interface Either<out L, out R>`), `is Left` / `is Right` branches smart-cast and may use `reified`-free pattern checks; type erasure means you match the raw subtype, not its type arguments — `is Left<*>` style. Covariance (`out`) lets you handle `Either<*, *>` polymorphically. ## Smart-cast through nesting Matching `is UserEvent` smart-casts to `UserEvent`; an inner `when (e)` then smart-casts further to `Login`/`Logout`. Each level only sees members of the type proven so far.

  • If you match the intermediate sealed parent in one branch, what happens when a new leaf is added under it?
    Nothing breaks at that `when` — the parent branch already covers the new leaf. Only an inner `when` that enumerated leaves would fail to compile, which is often exactly the granularity you want.
  • Why does a nullable sealed subject still need explicit null handling?
    Because `null` is a legal value of `T?`; the closed set of subtypes doesn't include `null`, so exhaustiveness isn't satisfied until `null` is matched or eliminated.

Like sorting mail: you can route everything for 'Floor 3' in one bin (parent), or have a separate slot per office (leaf) — the slot-per-office version forces you to add a slot when a new office opens.

saying these in an interview costs you the question

  • Forgets `null` must be handled for nullable sealed subjects
  • Thinks matching the parent forces listing all leaves
  • Believes generic type arguments are checkable at runtime in branches
  • Doesn't realize nested `when` smart-casts further
  • Assumes new leaves always break every `when` regardless of granularity

context