skip to content

How do branch guards (`if`/`when` with conditions) interact with exhaustiveness over a sealed type, and when would you deliberately keep or avoid an `else`?

level: seniorimportance: nice to knowfreq 25%

answer

  1. Guarded branch alone ≠ subtype covered
  2. Kotlin 2.1 added `is X if cond ->` syntax
  3. Keep one unconditional branch per subtype
  4. `else` on sealed = hides new subtypes (smell)
  5. Smart-cast applies before the guard runs

basics

~20 s

If a branch only matches a subtype under an extra condition, the compiler can't prove every value is handled, so you must add another branch or an else. Use else only when you truly accept any other case; otherwise list subtypes so new ones break the build.

solid answer

~50 s

Plain `is SubType ->` branches contribute to exhaustiveness. But a branch with an **extra condition** — historically `is X -> if (...) a else b`, or with Kotlin 2.1's guard syntax `is X if cond ->` — does **not** by itself prove the `X` case is fully covered, because the guard might be false. The compiler then still requires the full `X` case to be reachable some other way, or an `else`. Best practice: keep one unconditional branch per subtype so the closed set drives compile-time completeness; push intra-subtype logic *inside* the branch rather than guarding the branch head. Reserve `else` for genuinely open subjects (non-sealed inputs, defensive boundaries) — on a sealed type `else` is usually a smell because it hides newly added subtypes. Guards are great for readability but should never be the *only* way a subtype is matched.

code

kotlin · 10 lines
kotlin
sealed interface Animal
data class Cat(val hungry: Boolean) : Animal
data class Dog(val name: String) : Animal

// Kotlin 2.1 guards: guarded Cat branch needs an unconditional Cat backstop
fun act(a: Animal): String = when (a) {
    is Cat if a.hungry -> "feed"   // a smart-cast to Cat in the guard
    is Cat             -> "pet"    // covers remaining cats -> exhaustive
    is Dog             -> "walk:${a.name}"
}                                  // no else needed

go deeper

for a junior

Can write conditional logic inside a single is branch without breaking exhaustiveness.

for a middle

Knows a condition on a branch can force an extra branch or else and keeps an unconditional branch per subtype.

for a senior

Uses Kotlin 2.1 guards correctly with backstops and argues when else on a sealed type is a smell vs justified.

for a principal

Sets team conventions balancing guard readability against compile-time exhaustiveness and library forward-compatibility trade-offs.

## How exhaustiveness counts branches The compiler proves exhaustiveness by checking that the **set of unconditional subtype branches** covers every subtype. A branch whose match depends on a runtime condition is treated as *possibly not taken*, so it doesn't fully "claim" that subtype. ## Guard syntax (Kotlin 2.1+) Kotlin 2.1 introduced **guard conditions** in `when` with a subject: ```kotlin when (animal) { is Cat if animal.hungry -> feed() is Cat -> pet() // catches the non-hungry cats is Dog -> walk() } ``` The **guarded** `is Cat if ...` branch alone does not make the `Cat` case exhaustive — you still need an unconditional `is Cat ->` (or an `else`) so every `Cat` is handled. The compiler enforces this. ## Pre-2.1 idiom Before guards, people wrote: ```kotlin is Cat -> if (animal.hungry) feed() else pet() ``` Here the single unconditional `is Cat ->` branch *does* count for exhaustiveness; the conditional logic lives inside it. ## Designing for compile-time completeness Prefer **one unconditional branch per subtype** and put conditional logic inside: - Exhaustiveness stays driven by the closed subtype set. - Adding a new subtype breaks the `when` (good). Use guards mainly to flatten readability, always backed by an unconditional fallthrough branch for the same subtype. ## When to keep `else` - The subject is **not** a closed type (e.g. an `Int`, `String`, or a non-sealed class hierarchy) — `else` is required. - A deliberate **defensive boundary** (parsing untrusted input mapped to an open type). - Forward-compatibility across a **published library** where consumers shouldn't break when you add internal variants — though this trades away the compile-time signal. ## When to avoid `else` on a sealed type - Internal application code: omit `else` so new subtypes surface as compile errors. - `else -> error("unreachable")` on a sealed type is an anti-pattern: it converts a compile-time guarantee into a runtime exception. ## Smart-cast still applies Within a guarded branch the subject is smart-cast to the matched subtype before the guard runs, so `is Cat if cat.hungry` can read `Cat` members in the guard. ## Summary lever Guards = expressiveness; unconditional subtype branches = exhaustiveness. Keep them complementary, not substitutes.

  • Why doesn't a single guarded `is Cat if a.hungry ->` branch make the `Cat` case exhaustive?
    Because when the guard is false the branch isn't taken, leaving non-hungry cats unhandled; the compiler needs another branch (unconditional `is Cat` or `else`) to cover them.
  • When is `else` legitimately required even with a sealed subject?
    When you mix in guards that leave some subtype only conditionally covered, or when the subject is widened to a non-closed type; otherwise on a pure sealed `when` `else` is usually avoidable and undesirable.

A guard is a 'maybe' lane at a tollbooth — you still need a default lane so no car is left without a way through.

saying these in an interview costs you the question

  • Thinks a guarded branch fully covers its subtype
  • Defaults to `else -> error(...)` on sealed types
  • Unaware of Kotlin 2.1 guard syntax or its exhaustiveness rules
  • Puts all logic in guards, losing compile-time completeness
  • Claims `else` is always safer with no downside

context