skip to content

In an exhaustive sealed switch with no default, how are null and an unexpected runtime subtype handled? Explain case null and MatchException.

level: seniorimportance: should knowfreq 38%

answer

  1. exhaustiveness ignores null; null => NPE
  2. case null (or case null, default) to handle null
  3. no source default still has a hidden default
  4. hidden default throws MatchException
  5. MatchException = stale consumer / binary mismatch

basics

~20 s

A null value throws NullPointerException unless you add case null. If, at runtime, a subtype appears that wasn't known when the switch was compiled, the compiler's hidden fallback throws MatchException instead of silently doing nothing.

solid answer

~50 s

Exhaustiveness is checked over the non-null permitted subtypes, so null is a separate concern. By default a switch throws NullPointerException when the selector is null; to handle it you add case null (optionally combined as case null, default). Even without default, a sealed switch isn't truly fallback-free in bytecode: the compiler inserts a synthetic default that throws java.lang.MatchException. This only fires when a value reaches the switch whose type wasn't among the permitted subtypes known at compile time — typically a separate-compilation/binary-incompatibility situation where the sealed type was recompiled with a new subtype but the consumer wasn't. So an 'exhaustive, no default' switch behaves as: NPE on null (unless case null), normal case dispatch for known subtypes, and MatchException as a last-resort safety net for an out-of-band type. You never write that throw; it's generated to guarantee the switch always terminates with a defined outcome.

code

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

String describe(Shape s) {
    return switch (s) {
        case null     -> "none";    // explicit null handling
        case Circle c -> "circle";
        case Square q -> "square";
        // no default; compiler adds a hidden default -> throw MatchException
    };
}

go deeper

for a junior

Knows null causes an NPE in a switch and that case null can handle it.

for a middle

Can explain that exhaustiveness ignores null and that case null (or case null, default) handles it explicitly.

for a senior

Explains the synthetic default and MatchException as a fail-fast backstop and ties it to deciding null semantics deliberately.

for a principal

Reasons about separate compilation and binary compatibility, treats MatchException as a deployment/versioning signal, and advises against using default to suppress it.

## Two distinct 'edge' inputs An exhaustive sealed switch promises to handle every value of the sealed type. But two inputs sit outside the normal subtype enumeration and deserve their own treatment: **null** and **an unknown subtype appearing at runtime**. ## null Historically, `switch` throws `NullPointerException` if the selector is `null` — it tries to evaluate the selector and dereference it. This is unchanged for pattern switches: **exhaustiveness checking deliberately ignores null**, treating it as a separate axis. So: ```java String describe(Shape s) { return switch (s) { // exhaustive over Circle/Square case Circle c -> "circle"; case Square sq -> "square"; }; } ``` If `s` is null, this throws NPE before any case matches. To handle null gracefully you add an explicit label: ```java return switch (s) { case null -> "none"; // explicit null handling case Circle c -> "circle"; case Square sq -> "square"; }; ``` You may also combine `case null, default -> ...` to fold null into the default branch. Importantly, adding `case null` does *not* change whether the switch is exhaustive over the real subtypes — it's orthogonal. ## The hidden default and `MatchException` When you write a sealed switch with **no `default`** that the compiler accepts as exhaustive, you might assume the bytecode has no fallback. It does. The compiler inserts a **synthetic `default`** that throws **`java.lang.MatchException`** (introduced with pattern matching). Why is this needed if the switch is 'exhaustive'? Because exhaustiveness is proven against the permitted subtypes **as known when this switch was compiled**. Java supports **separate compilation**: the sealed type `Shape` and the consumer with the switch are different `.class` files that can be compiled and deployed at different times. Suppose `Shape` is later recompiled with a new `permits ... , Triangle`, and a `Triangle` instance flows into the *old* consumer's switch that only knows `Circle`/`Square`. Without a backstop, control would fall off the end of the switch with no value to return — undefined. The synthetic default throws `MatchException` to make this **fail fast and observably** rather than silently misbehave. So `MatchException` signals a **binary incompatibility / stale consumer**, not a normal program condition. You generally do not catch it; you fix the build so all consumers are recompiled against the current sealed hierarchy. ## Putting it together — the runtime contract of 'exhaustive, no default' For `switch (s)` where `s` is a sealed type and no `default` is written: 1. `s == null` → `NullPointerException`, unless a `case null` exists. 2. `s` is one of the compile-time-known permitted subtypes → its matching case runs. 3. `s` is some other subtype not known at this switch's compile time → synthetic default throws `MatchException`. ## Practical guidance - Decide null semantics explicitly: add `case null` if null is a valid input, otherwise let the NPE document that null is illegal. - Treat a `MatchException` in logs as a deployment/versioning bug — recompile consumers against the updated sealed type. - Don't add a real `default` solely to avoid `MatchException`; that would reintroduce the silent-swallow problem the design avoids.

  • Does adding case null make a switch exhaustive?
    No. Exhaustiveness is judged over the non-null permitted subtypes. case null only decides how null is treated; you still must cover every subtype (or use default) for exhaustiveness.
  • Under what realistic scenario does MatchException actually get thrown?
    When a sealed type gains a new permitted subtype and is redeployed, but a consumer class compiled against the older hierarchy isn't recompiled; an instance of the new subtype then hits the consumer's switch and the synthetic default throws MatchException.

saying these in an interview costs you the question

  • Assuming an exhaustive switch automatically handles null.
  • Thinking 'no default' means no fallback exists in bytecode.
  • Catching MatchException as normal flow instead of treating it as a versioning bug.
  • Believing case null affects whether the switch is exhaustive over subtypes.

context