Pattern Matching
Objective
Understand pattern matching: a pattern is a test performed on a value (the target) that, when it matches, both confirms the value's shape and extracts data from it into pattern variables — replacing the old "check the type, then cast" idiom with a single expression.
Use Cases
- Replacing
instanceof+ explicit cast with a singleinstanceofpattern that both checks and binds the variable. - Branching over a type hierarchy (e.g., a sealed
Shape) withswitchpattern labels instead of a chain ofif/else instanceof. - Destructuring a
recorddirectly in aninstanceoforcaselabel, pulling out its components without calling each accessor manually. - Adding extra conditions to a case with
whenguards, without nesting anifinside the case body. - Handling
nullexplicitly as its owncase nulllabel instead of guarding againstNullPointerExceptionbefore theswitch.
Deep Dive
instanceof: pattern match vs type comparison
instanceof still works as a plain type comparison, but it also acts as the pattern match operator when its right operand is a pattern:
javaif (s instanceof Rectangle r) {
System.out.println(r.length() * r.width());
}Rectangle r here is a type pattern: a type plus a single pattern variable. If s is a Rectangle, the test succeeds and r is initialized with s, already cast — no separate (Rectangle) s needed. If it fails, r is simply not in scope.
Type patterns and generics: still only reifiable types
Binding a pattern variable does not relax instanceof's long-standing restriction on generic types: the reference type in a pattern must still be reifiable — fully known at runtime — so a parameterized type like List<String> is illegal whether or not it binds a variable:
javastatic void plain(Object obj) {
if (obj instanceof List<String>) { } // error: Object cannot be safely cast to List<String>
}
static void withPattern(Object obj) {
if (obj instanceof List<String> list) { } // same error — the pattern variable changes nothing
}Both fail with the identical compiler error, Object cannot be safely cast to List<String> — pattern matching's cast-and-bind convenience doesn't come with a special exemption from erasure. The only generic forms instanceof accepts are the unbounded wildcard and the raw type, both of which are reifiable:
javaif (obj instanceof List<?> list) { // legal: unbounded wildcard is reifiable
System.out.println(list.size());
}
if (obj instanceof List list) { // legal: raw type is reifiable
list.add("oops"); // warning: [unchecked] unchecked call to add(E) as a member of the raw type List
}The raw-type pattern compiles cleanly on its own — the unchecked warning only appears where the raw type is actually used in a way that erasure can't verify (here, add), the same rule that already applies to any raw-typed variable outside of pattern matching.
Record patterns
A record pattern pairs a record type with a pattern list matching its components, so it can deconstruct nested data in one step:
javarecord Point(double x, double y) {}
if (obj instanceof Point(double a, double b)) {
System.out.println(a + b);
}Record patterns nest, so a record of records can be flattened directly in the pattern:
javarecord Line(Point start, Point end) {}
if (obj instanceof Line(Point(double x1, double y1), Point(double x2, double y2))) {
System.out.println(Math.hypot(x2 - x1, y2 - y1));
}Pattern matching with switch
Patterns can appear as case labels, turning a chain of instanceof checks into a single switch:
javastatic double getArea(Shape s) {
return switch (s) {
case Rectangle r -> r.length() * r.width();
case Circle c -> c.radius() * c.radius() * Math.PI;
default -> throw new IllegalArgumentException("Unrecognized shape");
};
}When Shape is sealed and every permitted subtype has a case, the compiler verifies exhaustiveness on its own and default can be dropped.
Guarded patterns (when)
A when clause attaches a boolean condition to a pattern label; the label only matches if the pattern and the guard both hold:
javaswitch (obj) {
case String s when s.length() == 1 -> System.out.println("Short: " + s);
case String s -> System.out.println(s);
default -> System.out.println("Not a string");
}Guards do not participate in dominance checking the way plain patterns do, so the compiler lets a guarded pattern sit before a constant label that it could also match.
Handling null explicitly
switch used to throw NullPointerException on a null selector. A pattern switch can instead match null directly:
javaswitch (obj) {
case null -> System.out.println("null!");
case String s -> System.out.println("String");
default -> System.out.println("Something else");
}null can only be combined with default (case null, default ->), never with another pattern label.
Exhaustiveness with sealed types
Without sealed, the compiler can't know every possible subtype, so a pattern switch expression without default fails to compile:
javainterface Shape {}
record Circle(double radius) implements Shape {}
record Rectangle(double length, double width) implements Shape {}
static double area(Shape s) {
return switch (s) { // error: the switch expression does not cover all possible input values
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.length() * r.width();
};
}Sealing Shape to exactly these two permitted subtypes lets the compiler prove the switch is exhaustive, so it compiles with no default:
javasealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double length, double width) implements Shape {}
static double area(Shape s) {
return switch (s) { // compiles: Circle + Rectangle cover every permitted subtype
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.length() * r.width();
};
}MatchException on stale recompilation
Add a third permitted subtype to the sealed hierarchy above:
javasealed interface Shape permits Circle, Rectangle, Triangle {}
record Triangle(double base, double height) implements Shape {}If Shape and Triangle are recompiled but the class containing area(Shape s) is not, that .class file still thinks Circle/Rectangle are exhaustive. Calling area(new Triangle(3, 4)) against the stale bytecode compiles fine at the time but throws MatchException at runtime — recompiling area too turns it back into a compile error demanding a Triangle case.
Pattern variable scope and fall-through
A pattern variable is only in scope for its own label's guard and body. In colon form, falling through past a pattern label into the next one is a compile error, because the next label doesn't see the previous one's variable:
javaswitch (obj) {
case Character c:
System.out.println("char");
// falls through
case Integer i: // error: variable c is already in scope, control falls through
System.out.println(i);
}Removing the fall-through (or scoping each case to a -> arrow, which never falls through) fixes it:
javaswitch (obj) {
case Character c -> System.out.println("char: " + c);
case Integer i -> System.out.println("int: " + i);
default -> System.out.println("other");
}Trade-offs
- Conciseness vs. familiarity — pattern matching removes the redundant cast after an
instanceofcheck, but reads unfamiliar to developers used to the classic "check then cast" idiom. - Exhaustiveness needs sealed types —
switchover patterns only skipsdefaultsafely when the target's hierarchy issealed; over a plain interface, a missingdefaultis a compile error:
javainterface Shape {} // not sealed
switch (s) { // error: not exhaustive, needs default
case Circle c -> ...;
case Rectangle r -> ...;
}- Recompilation hazard — if a sealed hierarchy gains a new permitted subtype and only some classes are recompiled, a previously exhaustive
switchcan throwMatchExceptionat runtime instead of failing to compile:
javasealed interface Shape permits Circle, Rectangle, Triangle {} // Triangle added, area() not recompiled
area(new Triangle(3, 4)); // MatchException at runtime- Pattern variable scope is narrow — a variable bound in a
caselabel is only in scope for that label's guard and body (or, in colon form, until the end of its statement group), so fall-through past a pattern label is a compile-time error:
javacase Character c:
// falls through
case Integer i: // error: c falls through into this label- A pattern variable doesn't grant generics an exemption from erasure —
obj instanceof List<String> listfails with the exact same compile error as the pattern-freeobj instanceof List<String>; only the unbounded wildcard (List<?>) or the raw type (List) are legal, and reaching for the raw type reintroduces ordinary unchecked-warning territory the moment it's used generically:
javaif (obj instanceof List<String> list) { } // error: Object cannot be safely cast to List<String>