Why architecture matters here

Pattern matching fails when misused — as a replacement for polymorphism or without sealed hierarchies. Architecture matters because sealed + exhaustive is what makes pattern matching safe and refactorable.

Advertisement

The architecture: every piece explained

The top strip is primitives. Sealed interface closes hierarchy. Records are data carriers. Switch expression returns a value. Type patterns — instanceof with capture.

The middle row is depth. Record patterns deconstruct. Guards (when) add conditional. Exhaustiveness compiler-verified for sealed. Nested patterns match inside inside.

The lower rows are practice. Refactoring class → sealed + record. Interop with visitor + polymorphism. Ops — style + team adoption.

Java pattern matching — sealed + records + switch + deconstruction + exhaustivenesslanguage-level data-oriented designSealed interfaceclosed hierarchyRecordsdata carriersSwitch expressionreturns valueType patternsinstanceof captureRecord patternsdeconstructionGuards (when)conditionalExhaustivenesscompiler checkNested patternsmatching in depthRefactoringclass → sealed + recordInteropwith visitors + polymorphismOps — style + team adoptiondecomposeguardprovecomposerefactorinteropinteropadoptadopt
Java pattern matching with sealed + records + switch.
Advertisement

Type patterns and where the binding variable is in scope

A type pattern fuses two operations that used to be written separately. o instanceof String s asks whether o is a String and, if it is, introduces s already narrowed to that type. The cast that used to sit on the following line is gone, and with it an entire class of bug where the test named one type and the cast named another.

What surprises people is not the binding but its scope. s is not scoped to the braces of the if; it is scoped to exactly the region of code in which the compiler can prove the pattern matched. That region is computed by the same flow analysis that decides whether a local variable is definitely assigned, so it follows the boolean structure of the condition rather than the block structure of the source.

static String label(Object o) {
    if (o instanceof String s && !s.isBlank()) {   // s is usable in the right operand
        return s.strip();
    }
    if (!(o instanceof Number n)) {                // negated test, early return
        return "unknown";
    }
    return n.intValue() > 0 ? "positive" : "non-positive";   // n is still usable here
}

Three consequences fall out of that one rule. In a && b, a binding introduced by a is visible inside b, because b is evaluated only when a was true. In !(o instanceof T t) || b the same holds for the right operand of ||, since that operand runs only when the negation was false. And after if (!(o instanceof T t)) return; the binding is live for the whole remainder of the enclosing block, because control cannot arrive there unless the pattern matched. The mirror image of that rule is why there is no else branch in which the binding of a positive test exists.

Two smaller rules bite during refactoring. A pattern variable may not shadow a local that is already in scope, so reusing the name in a second test at the same nesting level is a compile error rather than a silent redeclaration. And a pattern variable is an ordinary local variable: assigning to it is legal but destroys the reader's assumption that the name still denotes the value that matched, which is reason enough to treat it as read-only.

Switch as a statement and switch as an expression

The colon form and the arrow form are not cosmetic variants of each other. case X: introduces a label that control falls through: execution continues into the following label until it meets a break, which is why a missing break is one of the oldest defects in the language. case X -> introduces a single body - an expression, a block, or a throw - and control leaves the switch when that body finishes. Fall-through is not suppressed by convention here; the arrow form has no notion of it at all. A given switch block uses one form throughout, and mixing the two is rejected.

Orthogonally to that, a switch is either a statement or an expression. As a statement it runs for its effect. As an expression it produces a value, and that imposes two extra obligations: every arm must either produce a value or exit abruptly, and the labels must be exhaustive, because an expression has to have a value on every path.

An arrow arm whose body is a block cannot implicitly evaluate to its last statement, so the value is handed back with yield:

int weight = switch (reply) {
    case Ok ok   -> 1;
    case Moved m -> {
        int hops = countHops(m);
        yield hops * 2;                  // a block arm needs an explicit yield
    }
    case Failed f -> throw new IllegalStateException(f.reason());
};

yield exists because break was already spoken for. Inside a switch expression, break would be ambiguous with breaking an enclosing loop, and return is illegal because it would abandon the whole method rather than complete the expression. yield is a contextual keyword, so code that already uses the word as a method or variable name keeps compiling. The colon form can also be used as an expression, with every arm ending in yield - legal, and almost never worth reintroducing the fall-through risk for.

One rule catches teams migrating older code: a switch that uses pattern labels must be exhaustive even when it is a statement. The old permissiveness, where a statement switch silently did nothing for an unlisted value, survives only for the legacy label kinds.

Exhaustiveness - what the compiler can prove and what forces a default

Exhaustiveness is a static computation over the case labels, not a check performed at run time. The compiler asks a single question: do these labels, between them, cover every value the selector's declared type can take?

For an unsealed reference type the answer is almost always no. Object, a plain interface, a non-final class with unknown subtypes - none of these gives the compiler a closed set to reason about, so a switch over one needs either a default or an unconditional pattern such as case Object o. A sealed type supplies exactly that closed set, read from the permitted-subtypes record in the class file, and the walk is recursive. If Failed is itself sealed over Timeout and Rejected, then covering Ok, Moved, Timeout and Rejected is exhaustive without ever naming Failed.

A non-sealed member does not break the proof either. A label naming that member covers it and everything beneath it, so coverage still closes; what you lose by reopening a branch is the guarantee that the concrete shapes are enumerable, not the exhaustiveness argument itself.

Selector and labelsWhat the compiler has to work withNeeds a default?
Unsealed type or ObjectNo closed subtype setYes, or an unconditional pattern
Sealed type, every branch coveredPermitted-subtypes attributeNo
Sealed type, one branch missingPartial coverageYes - and the error names what is missing
Enum, every constant listedThe constant setNo
Any selector, every label guardedGuards are ignored for coverageYes
Constant labels over int or StringOpen value domainYes

The fifth row is the one worth internalising, and it gets its own section below. Note too that case Object o and default are interchangeable as far as the coverage proof is concerned but behave differently on null. The complementary argument - why you should resist writing default at all over a sealed hierarchy, and what separate compilation does to a switch that was exhaustive on the day it was built - is developed in Java records architecture.

permits mechanics and the constraints that make sealing checkable

sealed is a promise the compiler must be able to verify at two quite different moments: when the hierarchy itself is compiled, and much later when somebody compiles a switch over it, possibly against a prebuilt jar. That dual requirement explains every restriction the feature carries.

public sealed interface Reply
        permits Ok, Moved, Failed {}

public record Ok(byte[] body)        implements Reply {}   // records are implicitly final
public record Moved(String location) implements Reply {}

public abstract sealed class Failed implements Reply       // a nested closed set
        permits Timeout, Rejected {
    public abstract String reason();
    public abstract int attempts();
    public boolean retryable() { return attempts() < 3; }
}

public final      class Timeout  extends Failed { ... }
public non-sealed class Rejected extends Failed { ... }    // deliberately reopened

Each permitted type must directly extend or implement the sealed type, so the clause lists immediate children rather than a transitive closure. Each must itself be declared final, sealed, or non-sealed - one of the three, explicitly, so there is no such thing as an accidentally open member of a sealed hierarchy. Records and enum types are implicitly final and satisfy the requirement without a modifier. Local and anonymous classes cannot participate at all, since there would be no name to write in the clause.

Locality is the constraint that catches teams mid-refactor. If the sealed type is declared in a named module, every permitted subtype must live in that same module. If it lives on the classpath in the unnamed module, they must all live in the same package. You cannot seal a type in one artifact and permit implementations that arrive from another - the closed set has to be closed inside a boundary the compiler can actually see. The clause may be omitted entirely when every subtype is declared in the same source file, in which case it is inferred from that file.

None of this is enforced by the compiler alone. The permitted list is written into the class file, and Class.isSealed() together with Class.getPermittedSubclasses() exposes it reflectively. That is precisely how a downstream compilation, holding nothing but a jar, learns the set of cases it is obliged to cover.

Guards with when, and why a guard never counts as coverage

A guard attaches a boolean condition to a label: case Failed f when f.retryable() ->. The condition is evaluated only after the pattern has matched, so the bindings are already in scope and can be used inside it. That ordering is the entire point of the construct. when is a contextual keyword with meaning only in that position, so existing code using the word as an identifier is unaffected.

String action = switch (reply) {
    case Failed f when f.attempts() >= 3     -> "give-up";
    case Failed f when f.retryable()         -> "retry";
    case Failed f                            -> "escalate";   // required fallback
    case Moved m when m.location().isEmpty() -> "malformed-redirect";
    case Moved m                             -> "follow";
    case Ok ok                               -> "done";
};

The rule that dictates the shape of that block is simple to state: a guarded label is never unconditional. The compiler does not try to evaluate f.attempts() >= 3 || f.retryable() and conclude that the two guards between them account for every Failed. Doing so would mean reasoning about arbitrary program behaviour, and the language deliberately declines. A guarded pattern therefore contributes nothing to the exhaustiveness proof, and every guarded family needs an unguarded label beneath it or a default somewhere.

That same rule is what makes the ordering above legal rather than an error. Because a guarded pattern is not total, it does not dominate the unguarded form of the same pattern - guarded labels first, the unguarded one last, is the required order, and reversing it fails to compile.

At run time, a guard that evaluates to false is not a jump to default. Matching simply resumes at the next label in source order, so guards execute in sequence until one holds. Two operational consequences follow: keep guards cheap, because several may run before a match is selected, and keep them free of side effects, because a guard belonging to an arm that did not win has still executed.

Null in a switch - from a guaranteed NPE to an explicit case null

For most of the language's life, switching on a null reference threw NullPointerException unconditionally, before any label was even considered. That was defensible while a selector could only be an integer, a string or an enum constant, and it produced a familiar defensive shape: a null check wrapped around every switch, or an early return above it.

Pattern labels made that position untenable. A switch over a sealed type is meant to behave as a total function over that type, and null is a value the type admits. Requiring every such switch to be preceded by an if (x == null) pushes the one case the compiler cannot see back outside the construct that was designed to make the dispatch total in the first place.

The design that shipped keeps the old behaviour as the default and adds an opt-in. A null selector still throws unless the switch block contains a null label, and where such a label exists, null selects it:

return switch (reply) {
    case null     -> "no reply";
    case Ok ok    -> "ok";
    case Moved m  -> "moved";
    case Failed f -> "failed";
};

The subtlety that trips people is what does not absorb null. A default label does not. Neither does an unconditional type pattern such as case Object o, which is total over the selector's type for the purposes of the coverage proof and yet still does not receive null. When you want one arm for null and one for everything else, the labels are written together, as case null, default ->.

The reason this matters beyond convenience is that it moves null from an implicit precondition to a visible label. Reading a pattern switch, you can now see in the block itself whether null was considered; and where it was not, the failure remains a loud NullPointerException raised at the switch rather than a quietly taken default arm carrying a null binding into code that never expected one.

Total patterns, partial patterns and dominance ordering

A pattern is total - the specification says unconditional - for a type when every value of that type matches it. case Object o is total for any reference selector. case CharSequence cs is total when the selector is declared String and merely partial when the selector is declared Object. Totality is a property of the pair, pattern and selector type, never of the pattern alone, which is why the same label behaves differently in two switches that look alike.

The distinction drives three separate rules. A total pattern satisfies exhaustiveness on its own. A total pattern also makes any accompanying default unreachable, and the compiler rejects that combination instead of accepting dead code. And totality is the relation from which dominance is computed.

Dominance is the ordering rule. One label dominates another when every value matching the second also matches the first. Since labels are tried in source order, the second could then never be selected, so the compiler reports it rather than emitting a branch that can never run:

switch (obj) {                       // obj is declared Object
    case CharSequence cs -> ...;
    case String s        -> ...;     // error: dominated by CharSequence
}

switch (reply) {                     // reply is declared Reply
    case Reply r  -> ...;            // unconditional for the selector type
    case Ok ok    -> ...;            // error: dominated by the preceding pattern
}

The ordering that always works is most specific first: constant labels before any type pattern that would swallow them, subtypes before supertypes, guarded forms before the unguarded form. A null label may sit anywhere it reads well, because it takes part in no dominance relation - no type pattern matches null, so nothing can dominate it and it dominates nothing. default is exempt for a different reason: it is the fallback wherever it is written, though writing it last is the only readable choice.

These errors are worth welcoming. The equivalent mistake in a chain of if and else if compiles happily and leaves behind a branch that is simply never taken.

When matching fails at run time

The feature is designed so that most failures are compile errors, but a few run-time outcomes exist and are worth distinguishing.

The ordinary outcome is no match, and it is not exceptional. A switch that was not exhaustive at compile time carries a default, so "nothing matched" simply means the default arm ran; a pattern in an if that does not match takes the else path.

The second is a null selector with no null label, described above: NullPointerException, raised by the switch itself before any label is tried.

The third is the interesting one, and it is what java.lang.MatchException exists to represent - a match that had already committed but could not complete. The clearest instance is an accessor that throws while a deconstruction pattern is being evaluated: the type test has already succeeded, so the failure surfaces wrapped rather than raw. Guards are treated differently, and deliberately so: an exception thrown out of a guard propagates as itself, because a guard is ordinary user code evaluated in an ordinary position.

The related situation is a switch that was proved exhaustive against a sealed hierarchy which has since gained a subtype, with the consumer never rebuilt. No fallback branch was emitted, because none was reachable when the code was compiled, so the run-time behaviour has to be a failure at the switch rather than a silent fall-through or an unspecified value. This is a build-hygiene problem rather than something to catch and handle: keep a sealed hierarchy and everything that switches over it inside a single build unit. Java records architecture develops that argument in the context of algebraic data types.

Where record patterns and deconstruction fit

Everything above is the general machinery: a pattern tests and binds, a switch dispatches over an ordered list of labels, guards refine a case, sealing closes the set the compiler reasons about. Record patterns are the layer built on top of it - a pattern that matches a record type and then applies a sub-pattern to each component, nesting to arbitrary depth, with its own rules for null in component position and its own reasons for preferring var. Those rules, and the case for modelling a domain as a sealed interface over records, are covered in Java records architecture rather than repeated here. The dividing line is worth holding in mind while reading either article: nothing described above requires records, and every mechanism described above - flow scoping, arrow arms, yield, guards, dominance, null labels - applies unchanged once the sub-patterns are record patterns.

Pattern matching is one mechanism wearing several syntaxes. A pattern tests and binds in a single breath, and the binding's scope is computed by flow analysis rather than by braces, which is why it survives an early-return negation. Switch adds dispatch on top: arrow arms abolish fall-through, yield returns a value from a block arm, sealing hands the compiler a closed set to prove coverage against, guards refine a case while contributing nothing to that proof, and case null turns the language's oldest implicit precondition into a label you can actually see.