Pattern matching
instanceof patterns, switch patterns, record deconstruction and guards — the features that make sealed hierarchies worth having.
Pattern matching is Java's answer to a shape of code that appeared everywhere: test the type, cast, pull out the fields, branch. It arrived in pieces, instanceof patterns in Java 16, switch patterns and record patterns in Java 21, and together with records and sealed types it gives Java a way to model data as a closed set of shapes and to process it with a switch the compiler checks for completeness. This lesson is the syntax, the two rules the compiler enforces (dominance and exhaustiveness), and what a pattern switch becomes in bytecode, which is not the jump table you might expect.
Pattern matching for instanceof (Java 16)
// Before
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// After
if (obj instanceof String s) {
System.out.println(s.length());
}s is a pattern variable, in scope wherever the compiler can prove the test passed — including after an early return:
if (!(obj instanceof String s)) return;
System.out.println(s.length()); // s is in scope here
if (obj instanceof String s && s.length() > 3) { ... } // && is fine; || is notThe scoping rule is definite matching: s is in scope in any statement the flow can only reach when the instanceof was true. With && the right-hand side runs only if the left was true, so s is usable; with || it might not have been, so it is not.
Pattern matching for switch (Java 21)
static String describe(Object o) {
return switch (o) {
case null -> "null";
case Integer i when i < 0 -> "negative int " + i;
case Integer i -> "int " + i;
case String s -> "string of " + s.length();
case int[] arr -> "int array of " + arr.length;
default -> "something else";
};
}- Type patterns in
caselabels, binding a variable. - Guards with
whenfor extra conditions. case nullhandled explicitly; without it, anullselector throwsNullPointerExceptionasswitchalways did.- Dominance: a case that would never be reached because an earlier one covers it is a compile error.
case Integer ibeforecase Integer i when i < 0fails, andcase CharSequence csbeforecase String sfails, because the compiler checks that later patterns are not subsumed.
Record patterns (Java 21)
Destructure a record in the pattern:
record Point(int x, int y) {}
record Line(Point from, Point to) {}
static double length(Object o) {
return switch (o) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
Math.hypot(x2 - x1, y2 - y1);
default -> 0;
};
}Nested patterns go as deep as the records do, and var inside a pattern infers each component. Record patterns also work in instanceof: if (o instanceof Point(int x, int y)).
With sealed types: exhaustiveness
sealed interface Shape permits Circle, Square, Rect {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
record Rect(double w, double h) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Square q -> q.side() * q.side();
case Rect r -> r.w() * r.h();
}; // no default: the compiler verifies every permitted subtype is covered
}Add a Triangle to permits and every such switch fails to compile until it handles it. That is the point: the type system tracks completeness. A default would silence the check, so leave it out when the selector is sealed. Exhaustiveness is also computed through record patterns: a switch on Pair<Shape, Shape> with record patterns for each combination is exhaustive only if all nine combinations are covered, and the compiler will tell you which is missing.
Under the hood: typeSwitch and the cost of the check
A classic switch on an int compiles to tableswitch or lookupswitch, a jump table. A pattern switch cannot: the cases are types, and there is no integer to index with. Instead, javac emits an invokedynamic whose bootstrap is java.lang.runtime.SwitchBootstraps.typeSwitch, with the case types as static arguments. The call site takes the selector object and a restart index and returns the index of the first matching case at or after that index. The rest of the method is an ordinary lookupswitch on that integer, with each case doing a checkcast and, for guards, evaluating the when and, if it fails, calling typeSwitch again with the restart index set to the next case.
aload selector; iconst_0 // selector, restart index 0
invokedynamic typeSwitch(Object, int) → int // SwitchBootstraps: which case matches?
lookupswitch { 0: L0, 1: L1, 2: L2, default: Ldef }
L0: aload selector; checkcast Circle; … // bind c, run the arm
L1: … when-guard fails → iconst_2; goto invokedynamic (restart from case 2)Inside the bootstrap, the JDK builds a method handle that walks the case types in order with isInstance checks. Java 21's implementation is a linear scan; Java 23 and later (JDK-8291914 and follow-ups) generate a bytecode class per call site that tests types more cleverly, and for enum and sealed selectors can turn parts of it into a real table. Either way the cost is a chain of type tests per evaluation, which is fine for a handful of cases and worth knowing for a switch with thirty arms in a hot loop, where the order of the cases decides how many checks run. case null is handled before the bootstrap: the compiler emits an explicit null check and jumps to that arm, which is why null costs nothing and why, without case null, the NPE is thrown by the generated code, not by the bootstrap.
invokedynamic call site walks the cases in order and returns an index, and the ordinary switch does the rest.Exhaustiveness is a compile-time property. The compiler still emits a default arm that throws MatchException, for the case where a sealed hierarchy gained a subtype after this class was compiled and the old class file is run against the new one, a separate-compilation problem the check cannot prevent.
Walkthrough: the guard that reordered itself out of existence
A pricing module classified events:
static BigDecimal fee(Event e) {
return switch (e) {
case Trade t when t.amount().compareTo(LIMIT) > 0 -> t.amount().multiply(HIGH_RATE);
case Trade t -> t.amount().multiply(BASE_RATE);
case Refund r -> r.amount().negate();
};
}- A refactor "tidied" the cases alphabetically by pattern and then by guard, which put the unguarded
case Trade tbefore the guarded one. The compiler rejected it: the guarded case is dominated, unreachable. Good, the rule caught a bug before it existed. - The engineer, pushed by the error, moved the guard into the arm:
case Trade t -> t.amount().compareTo(LIMIT) > 0 ? … : …. It compiled. It was correct. Both forms are fine; the compiler only refuses the unreachable one. - Six months later a
Transferwas added topermits. Every exhaustiveswitchonEventfailed to compile, and each of the seven sites got a real decision instead of a silent fall-through to adefaultnobody had written. The one site that had adefault -> ZERO(added "to be safe") charged nothing for transfers for a week. - The
defaultwas removed, the fee added, and the team's rule became: nodefaulton a sealed selector. - Under load testing, the
switchcost nothing measurable: three type tests per event, inlined by the JIT once the selector types at that site had been seen.
Dominance protects you from a case that cannot run; exhaustiveness protects you from a case you forgot. A default on a sealed type trades the second protection for nothing.
Practical patterns
Replacing visitor. Sealed interface + records + switch is the modern visitor pattern: the data is closed, the operations are open, and adding an operation is one new method with one exhaustive switch.
Parsing results. sealed interface Result<T> permits Ok, Err with record Ok<T>(T value) and record Err<T>(String message); callers switch, and the compiler makes them handle both.
Event handling. switch (event) { case OrderPlaced(var id, var total) -> …; case OrderCancelled(var id) -> …; } reads as a specification.
Generic record patterns infer type arguments: case Ok<String>(var v) when the selector is Result<String>, and case Ok(var v) infers them from the selector.
Where the compiler will stop you
- Mixing pattern labels with constant labels of the wrong kind (
case 1alongsidecase Integer ion anObjectselector is fine; on anIntegerselector the constant must come first). - A guard that is always true or uses the pattern variable before it is bound.
- Falling through from a pattern case with the old colon syntax when the next case binds a variable: the variable would be unassigned.
- A
defaultbefore a pattern case, which would dominate it.
Try it yourself
Which lines fail to compile, and why?
static String f(Object o) {
return switch (o) {
case CharSequence cs -> "cs"; // A
case String s -> "s"; // B
case Integer i when i > 0 -> "pos"; // C
case Integer i -> "int"; // D
default -> "other";
};
}Answer
B fails: String is a subtype of CharSequence, so A dominates B and B can never match. Swap them. C before D is fine: a guarded pattern does not dominate the unguarded one that follows, but the reverse order would fail. default last is fine on an Object selector, and required, since Object is not sealed.
Does it compile, and what does it print?
sealed interface S permits A, B {}
record A(int n) implements S {}
record B(String t) implements S {}
static String g(S s) {
return switch (s) {
case A(var n) when n > 5 -> "big A";
case B(var t) -> "B " + t;
};
}Answer
It does not compile: not exhaustive. The guarded case A(var n) when n > 5 does not cover A with n <= 5, and the compiler ignores guards when computing exhaustiveness (it cannot reason about n > 5), so A is uncovered. Add case A a -> "small A" or an unguarded case A(var n). This is the rule people hit first with guards: a guarded case never contributes to exhaustiveness.
Null and the bootstrap
switch (o) { case String s -> 1; default -> 0; } with o == null: what happens, where is the exception thrown from, and how do you change it?
Answer
NullPointerException, thrown by the compiler-generated null check in your method before the typeSwitch call site runs; the stack trace's top frame is your method, not SwitchBootstraps. default does not catch null. Add case null -> 0 or case null, default -> 0 to handle it explicitly. This matches the pre-pattern switch on strings and enums, which also threw on null.
Misconceptions
- "A pattern
switchcompiles to a jump table on the class." It compiles to aninvokedynamictypeSwitchthat walks the case types in order and returns an index; the order of cases is the order of tests. - "
defaulton a sealed type is harmless." It disables exhaustiveness checking, which is the reason sealed types exist. Omit it. - "A guarded case helps exhaustiveness." The compiler ignores guards for coverage. Only unguarded patterns count.
- "
defaultcatchesnull." Onlycase nulldoes; otherwise anullselector throws, as it always did. - "Pattern variables are scoped to the
ifblock." They are scoped by definite matching: anywhere the flow proves the test passed, including afterif (!(o instanceof T t)) return;.
Going deeper
- JEP 394 (
instanceofpatterns), JEP 441 (patternswitch), JEP 440 (record patterns), JEP 409 (sealed classes). java.lang.runtime.SwitchBootstrapsJavadoc, andjavap -con a pattern switch to see thetypeSwitchcall site and restart loop.- JLS §14.11.1 (switch labels, dominance) and §14.11.1.1 (exhaustive switch).
- Brian Goetz, "Data Oriented Programming in Java", the design essay that ties records, sealed types and patterns together.
- JEP 455 and JEP 488 (primitive types in patterns, preview) for where the feature is going.