A teammate argues every sealed `when` should have a defensive `else -> error("unreachable")` 'just in case'. Critique this from a maintainability and correctness standpoint, and describe when it is actually justified.
answer
- else -> error() silences compile-time exhaustiveness
- Trades build error for latent runtime crash
- Lose the cross-codebase to-do list on new subtypes
- Justified: unbounded type, cross-module lib, untrusted data
- Prefer Unknown subtype or real default over error()
basics
~20 sAdding else -> error(...) to a sealed when usually hurts. It silences the compiler check, so when someone adds a new case the code still compiles and only fails at runtime. Without else, the build breaks and points you to every place to fix.
solid answer
~50 sFor a `when` over a sealed/enum subject, omitting `else` lets the compiler **guarantee exhaustiveness at compile time**: introduce a new subtype/entry and every such `when` fails to compile, giving a precise, complete to-do list. A defensive `else -> error("unreachable")` **trades that compile-time guarantee for a runtime crash**: the code compiles, the new case slips through to `else`, and you only discover it when production hits that path. So as a blanket rule it is an anti-pattern — it converts a build error into a latent runtime failure. It is justified in narrow cases: (1) the subject is **unbounded** (`Int`, `String`), where exhaustiveness without `else` is impossible; (2) a **library consumer** matching a sealed type from another module that the authors may extend, where you genuinely want resilience plus a loud failure; (3) defending against **invalid runtime data** (e.g. an enum decoded from JSON with an unknown value). Even then, prefer a meaningful default over `error()` when a sensible one exists, and document why the branch is unreachable.
go deeper
Recognizes that omitting else lets the compiler catch missing cases and a blanket else hides them.
Explains the compile-time-vs-runtime trade-off and that the new case would route to else and crash later.
Identifies the legitimate exceptions (unbounded types, untrusted data) and suggests an Unknown subtype.
Frames it as failure-surface policy, weighs cross-module/library evolution, and sets a documented-exception rule rather than a blanket style mandate.
## The default position: omit `else` on sealed/enum `when` Without `else`, the compiler verifies the `when` covers **every** subtype/entry. The benefit is *temporal*: it pays off when the type changes later. ```kotlin sealed interface Event data object Start : Event data object Stop : Event fun handle(e: Event) = when (e) { Start -> start() Stop -> stop() } // Add `data object Pause : Event` -> THIS won't compile until handled. ``` ## Why `else -> error("unreachable")` is usually harmful ```kotlin fun handle(e: Event) = when (e) { Start -> start() Stop -> stop() else -> error("unreachable") // <- silences the compiler } ``` - Adding `Pause` now **compiles fine**. The new case routes to `else` and throws **at runtime**, possibly only on a rare path. - You lose the compiler's exhaustive **to-do list** across the codebase — the single biggest value of sealed types. - "Unreachable" becomes a lie the moment the hierarchy grows; the comment/assert rots. - Net effect: a guaranteed compile-time error is downgraded to a probabilistic runtime error. That is strictly worse for correctness and maintainability. ## When a defensive `else` is genuinely justified 1. **Unbounded subject.** For `Int`, `String`, or an open class the set is infinite; `else` is mandatory, not optional. 2. **Cross-module / library consumer.** If you consume a sealed type from a dependency that the authors might extend in a future version (and that future version could ship without recompiling you), an `else` makes your code resilient. Pair it with a loud-but-recoverable failure or a sensible default, not a silent no-op. 3. **Untrusted runtime data.** An enum value decoded from JSON, a database column, or a network message can be a value your code didn't compile against (or a deserializer mapped an unknown). A defensive branch is correct defensive programming here. ## Better alternatives to a bare `error()` - Provide a **real default** when one exists (e.g. "treat unknown as no-op" only if that's truly correct). - If you must assert unreachability, prefer the compiler check (omit `else`) over `error()`; reserve `error()`/`TODO()` for the unbounded-type case. - For untrusted input, map unknowns to an explicit `Unknown` sealed subtype so the `when` stays exhaustive *and* the unknown case is first-class. ## Principal-level framing The choice is about **where failures surface**: compile time (cheap, complete, early) vs runtime (expensive, partial, late). Sealed + exhaustive `when` deliberately pushes failures to compile time. A blanket defensive `else` reverses that on purpose-defeating grounds, so it should be a **deliberate, documented exception**, not a style rule.
- How would you keep exhaustiveness while safely handling an unknown enum value decoded from JSON?Model an explicit `Unknown` (sealed subtype or enum entry) and map unrecognized input to it. The `when` stays exhaustive and the unknown case is handled deliberately rather than via a catch-all `else`.
- Is `else -> error("unreachable")` ever better than omitting `else`?Only when the subject is unbounded (so omitting `else` is impossible) or you're a cross-module consumer wanting resilience to upstream additions; otherwise omitting `else` is strictly safer.
It's like replacing a wall-mounted checklist that blocks the door until every item is done with a sticky note that says 'probably fine' — work still ships, but the gaps surface later, in production.
saying these in an interview costs you the question
- Advocating defensive `else` on all sealed `when`s as a rule
- Not recognizing it converts compile errors into runtime crashes
- Treating `error("unreachable")` comments as reliable
- Ignoring legitimate cases (unbounded, cross-module, untrusted input)
- No distinction between trusted compile-time types and untrusted runtime data