skip to content

How do record patterns combine with switch and sealed types, including exhaustiveness, guards, and dominance ordering?

level: seniorimportance: should knowfreq 48%

answer

  1. Case labels can be record patterns: branch on type + structure
  2. Sealed + all subtypes covered => exhaustive, no default needed
  3. New permitted subtype => non-exhaustive switch fails to compile
  4. 'when' = guard; guarded case isn't exhaustive alone, put specific first
  5. case null for null; MatchException when nothing matches at runtime

basics

~20 s

You can use record patterns as switch case labels to branch on the shape of data. If the type being switched is a sealed hierarchy and every permitted subtype is covered, the switch is exhaustive and needs no default. You can refine a case with 'when' (a guard), and you must order cases so a more specific one comes before a more general one.

solid answer

~50 s

In a switch, each case label can be a record pattern, so you branch on both type and structure and bind components per branch. When you switch over a sealed type, the compiler checks exhaustiveness: if every permitted subtype is matched (directly or via patterns), no default arm is needed, and adding a new subtype later turns the incomplete switch into a compile error — a safety win over a runtime default. A 'when' clause adds a boolean guard to a case (e.g. 'case Point(var x, var y) when x == y'); a guarded case only matches if the guard is also true, so an unguarded fallback for the same type should follow it. Cases must be ordered by specificity: a label that dominates (would catch everything a later one would) must not precede it, or you get a compile error. At runtime, if no label matches a non-null value that the compiler couldn't prove exhaustive, a MatchException is thrown; 'case null' handles null explicitly.

code

java · 13 lines
java
sealed interface Shape permits Point, Line, Circle {}
record Point(int x, int y) implements Shape {}
record Line(Point start, Point end) implements Shape {}
record Circle(Point center, int radius) implements Shape {}

static String classify(Shape s) {
    return switch (s) {                              // exhaustive: no default needed
        case Point(var x, var y) when x == y -> "diagonal point";
        case Point(var x, var y)             -> "point";
        case Line(Point(var x1, var y1), Point(var x2, var y2)) -> "line";
        case Circle(Point c, int r)          -> "circle r=" + r;
    };
}

go deeper

for a junior

Knows record patterns can appear as switch case labels and that switch can branch on the kind of value.

for a middle

Explains exhaustiveness over a sealed type (no default needed when all subtypes covered) and the basic 'when' guard.

for a senior

Reasons about dominance ordering, why guarded cases aren't exhaustive alone, null handling (NPE vs case null), and MatchException on unmatched runtime values.

for a principal

Designs closed sealed hierarchies for compiler-checked totality, weighs no-default exhaustiveness as a maintenance lever, understands separate-compilation skew leading to MatchException, and positions this as ADT/data-oriented programming replacing visitor patterns.

## Record patterns as case labels Java's `switch` evolved from constant-matching into **pattern matching**. A case label may be a **type pattern** (`case Point p`) or a **record pattern** (`case Point(int x, int y)`). With record patterns each branch tests the shape and binds the components it needs: ```java switch (shape) { case Point(var x, var y) -> handlePoint(x, y); case Line(Point(var x1, var y1), Point(var x2, var y2)) -> handleLine(x1, y1, x2, y2); default -> handleOther(); } ``` ## Sealed types and exhaustiveness A **sealed** type restricts which classes may extend/implement it via a `permits` clause, so the compiler knows the *complete* set of subtypes. Example: ```java sealed interface Shape permits Point, Line, Circle {} ``` When a `switch` **expression** (or a switch statement requiring completeness) is over a sealed type, the compiler verifies **exhaustiveness**: are all permitted subtypes covered by the case labels? If yes, **no `default` is required**. This is powerful: add a new permitted subtype (say `Triangle`) and every non-exhaustive switch over `Shape` becomes a **compile-time error**, forcing you to handle it — far safer than a silent runtime `default`. Note a subtlety: a *record pattern* with nested type patterns is not always provably total over the type, because an inner pattern could fail (e.g. a nested reference type test). The compiler considers a case to cover a subtype only when it can prove the match always succeeds for that subtype. ## Guards: the `when` clause A **guarded pattern** adds a boolean condition with `when`: ```java case Point(var x, var y) when x == y -> "on the diagonal"; case Point(var x, var y) -> "off the diagonal"; ``` The case matches only if the pattern matches **and** the guard is true. Because a guard can be false, a guarded case is **not** considered exhaustive on its own — you typically follow it with an unguarded fallback for the same shape. The unguarded case must come **after** the guarded one (specific before general). ## Dominance ordering Case labels are tried top-to-bottom. A label **dominates** a later one if everything the later one matches, the earlier one already matches. The compiler rejects a switch where an earlier label dominates a later, reachable label (the later case would be **unreachable**). Practical rules: more specific record patterns / guarded cases come first; broader type patterns and `default` come last. For example `case Shape s` (or `default`) must not precede `case Point(...)`. ## null and MatchException - By default a pattern switch **throws NullPointerException** on a null selector unless you add `case null` (or `case null, default`). So `case null -> ...` handles null explicitly. - At **runtime**, if the selector is non-null and matches no label in a switch the compiler treated as exhaustive only via the sealed contract, but the actual runtime class falls outside (e.g. separate compilation skew where a new subtype appears), a **MatchException** is thrown rather than silently falling through. This makes incomplete coverage fail loudly. ## Why this matters Record patterns + sealed types + switch give Java **algebraic-data-type style** programming: model a closed set of shapes, then handle each shape's structure in one exhaustive, compiler-checked switch. It replaces visitor boilerplate and instanceof chains with declarative, total, refactor-safe code.

  • Why prefer an exhaustive sealed switch with no default over one with a default?
    With no default, adding a new permitted subtype makes the now-incomplete switch a compile error, so the compiler points you at every place that must handle it. A default silently absorbs the new case, hiding the gap until it misbehaves at runtime.
  • Where must the unguarded version of a case go relative to its guarded version?
    After it. The guarded case ('case Point(...) when cond') is more specific and must precede the unguarded fallback ('case Point(...)'); reversing them makes the guarded case unreachable, a compile error.

saying these in an interview costs you the question

  • Claiming a guarded case alone makes a switch exhaustive
  • Putting a broad type pattern or default before a specific record pattern (dominance/unreachable error)
  • Assuming a pattern switch silently ignores null instead of throwing NPE without case null
  • Believing you always need a default even over a sealed hierarchy
  • Saying unmatched values fall through silently rather than throwing MatchException

context