skip to content

What does it mean for a switch over a sealed type to be exhaustive, and how does a sealed hierarchy let the compiler verify exhaustiveness without a default arm?

level: middleimportance: must knowfreq 55%

answer

  1. permits = closed, compile-time-known subtype set
  2. all cases covered => exhaustive => no default
  3. omit default so adding a subtype breaks compile
  4. switch expressions must be exhaustive
  5. null handled separately (case null / NPE)

basics

~20 s

A sealed type lists exactly which subtypes can exist. If your switch has a case for every permitted subtype, the compiler knows nothing else is possible and accepts it as complete (exhaustive) without a default branch.

solid answer

~40 s

A sealed class or interface declares a closed, compile-time-known set of subtypes via its permits clause. Because that set is fixed, the compiler can prove a switch is exhaustive: if every permitted subtype is covered by a case label, no other value (besides null, handled separately) is reachable, so a default arm is unnecessary. This applies to switch expressions and pattern-matching switches that require exhaustiveness. The real win is maintainability: when you add a new permitted subtype and recompile, every non-default switch that no longer covers all cases fails to compile, pointing you at exactly the spots to update. A default arm would instead silently swallow the new subtype, hiding the gap. So you deliberately omit default to keep the compiler as a safety net.

code

java · 12 lines
java
sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double s) implements Shape {}

static double area(Shape sh) {
    return switch (sh) {          // exhaustive: no default
        case Circle c -> Math.PI * c.r() * c.r();
        case Square sq -> sq.s() * sq.s();
    };
}
// Adding `record Triangle(...) implements Shape {}` to permits
// makes this switch fail to compile until Triangle is handled.

go deeper

for a junior

Knows a sealed type lists its subtypes and that covering all of them lets a switch skip default.

for a middle

Explains that the permits clause closes the subtype set so the compiler can prove exhaustiveness, and that this applies to switch expressions.

for a senior

Articulates the deliberate omission of default as a maintainability tool, contrasts with default swallowing new cases, and mentions null handling.

for a principal

Discusses the synthetic MatchException backstop, separate-compilation/binary-compatibility implications, non-sealed subtypes, and how this generalizes enum exhaustiveness into a domain-modeling discipline.

## The terms, from first principles **A `switch`** picks one branch of code based on a value. A **`switch` *expression*** (Java 14+) is a switch that *produces a value*, e.g. `int n = switch (shape) { ... };`, unlike a `switch` *statement* that just runs code. Because an expression must yield a value for *every* possible input, the compiler insists it be **exhaustive** — it must cover every input — otherwise some input would leave the expression with no value to return. **A sealed type** (finalized in Java 17) is a class or interface marked `sealed` with a `permits` clause listing exactly which types may directly extend or implement it. Example: `sealed interface Shape permits Circle, Square, Triangle {}`. No other type — anywhere, even in another file — can be a direct subtype of `Shape`. The set of subtypes is therefore **closed** and **known at compile time**. **Exhaustiveness** means: the set of `case` labels covers every value the selector could take. For an `int` selector that is practically impossible (4 billion values), so you need `default`. But for a sealed type, the universe of values is just "instances of the permitted subtypes" (plus `null`). The compiler enumerates the permitted subtypes and checks each is matched by some case. ## Why a sealed hierarchy enables it Before sealed types, any class could be subclassed by anyone, so the compiler could never know it had seen *all* possible subtypes — it had to assume an unknown subtype might appear at runtime, forcing a `default`. `sealed` removes that uncertainty: the compiler reads the `permits` clause, sees the *complete* list, and if your switch has a case for each one, it concludes no other type is reachable. Exhaustiveness is proven, and **no `default` is required**. ```java sealed interface Shape permits Circle, Square {} record Circle(double r) implements Shape {} record Square(double s) implements Shape {} double area(Shape sh) { return switch (sh) { // no default needed case Circle c -> Math.PI * c.r() * c.r(); case Square sq -> sq.s() * sq.s(); }; } ``` ## Why omit `default` on purpose This is the key engineering point. Suppose you add `record Triangle(...) implements Shape {}` to the `permits` list. Every switch that lacked a `Triangle` case now **fails to compile** with "the switch statement/expression does not cover all possible input values." The compiler hands you a to-do list of every place to update. Had you written a catch-all `default -> ...`, the code would still compile and the new `Triangle` would silently fall into `default` — a latent bug. So you trade the convenience of `default` for a compile-time guarantee that the hierarchy and its consumers stay in sync. ## Details and edge cases - **`null`:** by default `switch` throws `NullPointerException` on a null selector. Pattern switches may add `case null` to handle it; exhaustiveness checking treats null separately. Without `case null`, an exhaustive switch is still considered exhaustive — null just NPEs as usual. - **Statements vs expressions:** a `switch` *expression* is *always* required to be exhaustive. A `switch` *statement* using the new pattern/null labels is *also* checked for exhaustiveness; a legacy statement (e.g. plain `int`/`String` with no patterns) is not. - **Synthetic default / `MatchException`:** when a switch is exhaustive without `default`, the compiler may insert a hidden default that throws `MatchException` to cover the gap if, at runtime, a separately-compiled subtype appears that wasn't known at compile time (binary incompatibility). You never write this; it is a safety backstop. - **`non-sealed` subtypes:** a permitted subtype can be `non-sealed`, reopening it for further extension. Covering the `non-sealed` subtype's case still satisfies exhaustiveness because any deeper subtype *is-a* that permitted type. - **Enums** had a similar (weaker) story: a switch over an enum covering all constants in an expression is exhaustive too; sealed types generalize this to arbitrary class hierarchies.

  • If you add a new permitted subtype, what happens to existing switches — with and without a default arm?
    Without default: every non-exhaustive switch fails to compile, flagging the spots to update. With default: they keep compiling and the new subtype silently falls into default, masking the missing handling.
  • Does omitting default mean null is automatically handled?
    No. A null selector throws NullPointerException unless you add an explicit case null (optionally case null, default). Exhaustiveness is checked over the non-null permitted subtypes.

saying these in an interview costs you the question

  • Saying you should add a default to be safe — that defeats the compile-time check.
  • Claiming exhaustiveness works for any class, not just sealed (or enum) types.
  • Thinking a default and full case coverage are equivalent — default hides new subtypes.
  • Forgetting that null still NPEs unless a case null is present.

context