skip to content

How does sealed-type exhaustiveness in switch compare to enum switch exhaustiveness, and how would you use sealed hierarchies plus exhaustive switches as a domain-modeling technique?

level: principalimportance: nice to knowfreq 30%

answer

  1. enum: exhaustive over constants; same type, singletons
  2. sealed: exhaustive over subtypes; heterogeneous, data-carrying
  3. sealed + records + switch = sum types / pattern matching
  4. compile-time total coverage = refactoring safety
  5. Expression Problem: easy to add ops, hard to add variants

basics

~20 s

Enums let a switch be exhaustive by covering all constants; sealed types extend that idea to whole class hierarchies, where each subtype can carry its own data. Together with exhaustive switches they model closed sets of cases (sum types) safely.

solid answer

~50 s

Enums gave Java a limited form of exhaustiveness: a switch expression over an enum that covers every constant needs no default, and adding a constant breaks uncovered switches. But enum constants are singletons — they can't carry per-instance, differently-typed data. Sealed types generalize this to arbitrary class/record hierarchies: each permitted subtype is a distinct shape with its own fields, and an exhaustive switch (often with record deconstruction patterns) handles each. This is Java's encoding of algebraic 'sum types' (a closed choice among variants). The modeling technique: declare a sealed interface for a domain concept (an AST node, a command, a result, a state), make each variant a record implementing it, and process them with default-less exhaustive switches. You get closed sets the compiler enforces, data-carrying variants, and refactoring safety — adding a variant produces a compile error at every consumer. It's the OO/data-oriented complement to the visitor pattern, without the boilerplate.

code

java · 10 lines
java
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String message) implements Result<T> {}

static <T> String show(Result<T> r) {
    return switch (r) {                 // exhaustive, no default
        case Ok<T>(var v)  -> "ok: " + v;
        case Err<T>(var m) -> "err: " + m;
    };
    // Adding a third Result variant breaks this switch at compile time.

go deeper

for a junior

Recognizes that both enums and sealed types let a switch be exhaustive and skip default.

for a middle

Explains that sealed types generalize enum exhaustiveness to class hierarchies whose variants carry their own data.

for a senior

Uses sealed interface + records + exhaustive switch to model closed domains and contrasts it with the visitor pattern.

for a principal

Frames sealed exhaustiveness as algebraic sum types, reasons about the Expression Problem, public-API evolution/binary compatibility, and when a closed set is the right architectural choice.

## Two flavors of exhaustiveness **Enum exhaustiveness:** an `enum` is a fixed set of named constant instances, e.g. `enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES }`. A `switch` *expression* over a `Suit` that has a case for all four constants is exhaustive and needs no `default`. Adding `JOKER` to the enum breaks every such switch at compile time. This is valuable but limited: every constant is the *same type* (`Suit`) and a singleton — it can't carry distinct, per-variant data (you can attach fields to the enum, but every constant shares the same field set). **Sealed exhaustiveness:** a `sealed` interface/class fixes a set of *subtypes*, each of which can be a different class or record with its own fields and shape. `switch` over the sealed type that covers each permitted subtype is exhaustive with no `default`, and adding a subtype breaks uncovered switches — the same refactoring-safety mechanism, now over a *heterogeneous* set. ## Sum types and pattern matching In type theory a **sum type** (a.k.a. tagged union / variant) is a value that is *exactly one of* a closed set of alternatives, each possibly carrying its own data. Functional languages (Haskell, Rust, Scala) have these natively and rely on **exhaustive pattern matching** so the compiler verifies every alternative is handled. Java's `sealed` types + records + pattern `switch` are precisely this combination: ```java sealed interface Json permits JNull, JBool, JNumber, JString, JArray, JObject {} record JNull() implements Json {} record JBool(boolean b) implements Json {} record JNumber(double n) implements Json {} record JString(String s) implements Json {} record JArray(List<Json> items) implements Json {} record JObject(Map<String,Json> kv) implements Json {} String render(Json j) { return switch (j) { // exhaustive, no default case JNull() -> "null"; case JBool(var b) -> Boolean.toString(b); case JNumber(var n)-> Double.toString(n); case JString(var s)-> '"' + s + '"'; case JArray(var a) -> a.stream().map(this::render).collect(joining(",", "[", "]")); case JObject(var m)-> /* ... */ "{...}"; }; } ``` Each `case` uses **record deconstruction** to bind the variant's data directly. The switch is exhaustive because `Json` is sealed; add a `JComment` variant and `render` (and every other consumer) stops compiling until updated. ## The modeling discipline 1. **Identify a closed set of cases** — a value that is always exactly one of N known shapes: AST nodes, protocol messages, UI events, a `Result` that's `Ok` or `Err`, a state machine's states. 2. **Model the concept as a `sealed` interface**, each variant a `record` implementing it. Records give you immutable, data-carrying variants with `equals`/`hashCode`/deconstruction for free. 3. **Process with default-less exhaustive switches.** Omit `default` so the compiler enforces total coverage. ## Why this beats the alternatives - **vs. the Visitor pattern:** Visitor also achieves 'compiler tells me about a missing case' (add an abstract `visit` method → every visitor breaks), but at the cost of heavy boilerplate (an `accept` method per element, a visitor interface, double dispatch). Sealed + switch gets the same exhaustiveness with far less ceremony and keeps the logic local to one switch rather than scattered across visitor classes. - **vs. an enum with a 'kind' tag + casts:** the old idiom — an enum discriminator plus a base class and `instanceof`/cast — is unsafe (the compiler can't check you handled all kinds) and verbose. Sealed types make the discriminator the *type itself*. - **vs. open inheritance:** with non-sealed hierarchies the compiler can never prove exhaustiveness, so you're back to defensive defaults. ## Trade-offs and judgement (principal-level) - **Adding a variant is a breaking change to all consumers** — this is a *feature* for closed domains you own, but a *liability* for a public API where third parties write switches: you can't add a variant without breaking them. So expose sealed hierarchies as extension points only when you intend the set to be closed; otherwise keep them internal or provide a stable `default`-friendly visitor. - **Open vs. closed for evolution:** the Expression Problem trade-off — sealed/switch makes *adding operations* cheap (new method with its own switch) but *adding variants* expensive (touch every switch); classic OO inheritance is the reverse. Choose based on which axis changes more. - **Binary compatibility:** because exhaustiveness is compile-time, separately-compiled consumers can throw `MatchException` if the hierarchy grows underneath them — manage with coordinated recompilation. In short, sealed exhaustiveness elevates a runtime concern (did I handle every case?) into a compile-time guarantee, and combined with records and pattern matching gives Java idiomatic, boilerplate-light sum types for data-oriented domain modeling.

  • What is the Expression Problem, and how do sealed switches sit on it?
    It's the tension between easily adding new data variants vs. new operations. Sealed types + switch make adding operations cheap (one new method/switch) but adding variants costly (every switch must be updated). Classic OO inheritance is the opposite. Pick the encoding matching the axis that varies most.
  • Why might exposing a sealed hierarchy in a public API be risky?
    Consumers write exhaustive switches over your variants. Adding a new permitted subtype is then a source-incompatible change — their default-less switches stop compiling (and old binaries can throw MatchException). For evolvable public APIs, keep the set closed only if you truly won't add variants, or provide a stable visitor/default-friendly surface.

saying these in an interview costs you the question

  • Claiming enums and sealed types are interchangeable for modeling — enum constants can't carry distinct per-variant data.
  • Exposing a sealed hierarchy as a public extension point without realizing adding a variant breaks third-party switches.
  • Saying sealed switches eliminate the visitor pattern in all cases without weighing the Expression Problem trade-off.
  • Forgetting that default-less consumers can throw MatchException across separate compilation.

context