Pattern Matching
Pattern matching lets you test a value’s type and extract its contents in a single, concise step — no more redundant casts, no more boilerplate. Java has been rolling out pattern matching progressively since Java 14, with each release adding more expressive power.
The Problem Pattern Matching Solves
Before pattern matching, every instanceof check forced you to write the same thing three times: check the type, cast the value, then use it.
// Old-school approach (Java 15 and earlier)
Object obj = "Hello, world!";
if (obj instanceof String) {
String s = (String) obj; // redundant cast
System.out.println(s.toUpperCase());
}
This works, but it is noisy. The cast on line 3 carries zero new information — the compiler already knows obj is a String at that point. Pattern matching eliminates the ceremony.
Pattern Matching for instanceof (Java 16)
Java 16 (JEP 394) finalized type patterns for instanceof. You declare a binding variable directly in the test:
Object obj = "Hello, world!";
if (obj instanceof String s) {
System.out.println(s.toUpperCase());
}
Output:
HELLO, WORLD!
The variable s is bound automatically and is scoped to where it is definitely assigned — inside the if block (and the right-hand side of &&).
Note: The binding variable
sis a fresh, effectively-final copy. It is not the same reference asobj— it is just cast and named for you.
Negated Patterns
You can use the pattern in a negated check to exit early:
static void printLength(Object obj) {
if (!(obj instanceof String s)) {
System.out.println("Not a string");
return;
}
// s is in scope here — Java knows obj was a String
System.out.println("Length: " + s.length());
}
Combining with &&
The binding variable flows into the right-hand side of a logical &&:
Object value = "Java";
if (value instanceof String s && s.length() > 3) {
System.out.println("Long string: " + s);
}
Output:
Long string: Java
Warning: Do not use
||with a binding variable on the left side — the variable is not guaranteed to be assigned when the right side evaluates, so the compiler will reject it.
Pattern Matching in switch (Java 21)
The real leap comes with switch pattern matching, finalized in Java 21 (JEP 441). You can match on types directly in a switch expression or statement. See also Switch Expressions for the modern switch syntax foundations.
static String describe(Object obj) {
return switch (obj) {
case Integer i -> "Integer: " + i;
case Double d -> "Double: " + d;
case String s -> "String of length " + s.length();
case null -> "null value";
default -> "Something else: " + obj.getClass().getSimpleName();
};
}
public static void main(String[] args) {
System.out.println(describe(42));
System.out.println(describe(3.14));
System.out.println(describe("hello"));
System.out.println(describe(null));
System.out.println(describe(true));
}
Output:
Integer: 42
Double: 3.14
String of length 5
null value
Something else: Boolean
A few things to notice:
case nullis now a legal pattern — no moreNullPointerExceptionsurprise before the switch.- Cases are checked top to bottom in order. The first matching arm wins.
defaultcatches everything else.
Guarded Patterns (when clause)
Add a when guard to attach a boolean condition to a type pattern:
static String classify(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "Negative integer";
case Integer i when i == 0 -> "Zero";
case Integer i -> "Positive integer: " + i;
case String s when s.isEmpty() -> "Empty string";
case String s -> "String: " + s;
default -> "Other";
};
}
public static void main(String[] args) {
System.out.println(classify(-5));
System.out.println(classify(0));
System.out.println(classify(7));
System.out.println(classify(""));
System.out.println(classify("hi"));
}
Output:
Negative integer
Zero
Positive integer: 7
Empty string
String: hi
Tip: The
whenclause replaces the oldifguard inside case blocks. It is evaluated only after the type test succeeds, soiandsare already bound when the guard runs.
Record Patterns (Java 21)
Java 21 (JEP 440) finalized record patterns, which let you destructure a record directly in a pattern. Instead of extracting components yourself, the pattern unpacks them for you.
record Point(int x, int y) {}
record Line(Point start, Point end) {}
static void printPoint(Object obj) {
if (obj instanceof Point(int x, int y)) {
System.out.println("Point at (" + x + ", " + y + ")");
}
}
Output:
Point at (3, 7)
Nested Record Patterns
Record patterns can be nested to destructure deeply:
static void describeSegment(Object obj) {
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
System.out.printf("Line from (%d,%d) to (%d,%d)%n", x1, y1, x2, y2);
}
}
public static void main(String[] args) {
describeSegment(new Line(new Point(0, 0), new Point(5, 10)));
}
Output:
Line from (0,0) to (5,10)
Record Patterns in switch
Combine record patterns with switch for clean data processing:
sealed interface Shape permits Circle, Rect {}
record Circle(double radius) implements Shape {}
record Rect(double width, double height) implements Shape {}
static double area(Shape shape) {
return switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Rect(double w, double h) -> w * h;
};
}
public static void main(String[] args) {
System.out.printf("Circle area: %.2f%n", area(new Circle(3)));
System.out.printf("Rect area: %.2f%n", area(new Rect(4, 5)));
}
Output:
Circle area: 28.27
Rect area: 20.00
Notice there is no default case — the compiler knows Shape is a sealed interface with exactly two permitted types, so the switch is exhaustive.
Exhaustiveness with Sealed Types
When the switch selector type is a sealed class or interface, the compiler verifies that every permitted subtype is covered. If you forget a case, the code will not compile:
sealed interface Expr permits Num, Add {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
// Compiler error if you omit the Add case:
static int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
// No default needed — compiler confirms coverage
};
}
This exhaustiveness guarantee is what makes the combination of sealed types and pattern matching so powerful for domain modeling.
Dominance: Order Matters
In a switch with type patterns, a more specific pattern must come before a more general one. Placing a broader type pattern first would make a narrower one unreachable, which is a compile error:
// COMPILE ERROR — Integer is a Number, so case Integer is dominated
static String bad(Number n) {
return switch (n) {
case Number x -> "any number"; // dominates everything below
case Integer i -> "integer"; // unreachable — compiler rejects this
};
}
// CORRECT — specific type first
static String good(Number n) {
return switch (n) {
case Integer i -> "integer";
case Double d -> "double";
case Number x -> "some other number";
};
}
Feature Timeline
| Feature | Preview | Finalized | JEP |
|---|---|---|---|
Pattern matching for instanceof | Java 14, 15 | Java 16 | 394 |
Pattern matching for switch | Java 17–20 | Java 21 | 441 |
| Record patterns | Java 19, 20 | Java 21 | 440 |
Unnamed patterns & variables (_) | Java 21 (preview) | Java 22 | 456 |
Note: Unnamed patterns (using
_to ignore a component) were previewed in Java 21 and finalized in Java 22. They let you writecase Point(int x, _)to ignore the y-component entirely.
Under the Hood
instanceof Pattern Lowering
The compiler lowers obj instanceof String s to a normal instanceof test followed by a checkcast bytecode. The binding variable is simply the result of the cast stored in a new local variable slot. At runtime there is no extra overhead compared to the old manual approach — the JIT can inline and eliminate the checkcast if the type is already proven.
switch Pattern Dispatch
Pattern switch is compiled to a tableswitch or lookupswitch bytecode plus a helper method generated by the compiler (an internal typeSwitch or enumSwitch bootstrap). The JDK uses an invokedynamic instruction (via java.lang.runtime.SwitchBootstraps) to dispatch at runtime. This means the heavy lifting is handled by a bootstrap method the first time it runs, and subsequent calls are fast.
Record Deconstruction
Record patterns use the record’s accessor methods (x(), y(), etc.) to extract components. There is no reflection or special bytecode for deconstruction — it compiles down to regular method calls. The JIT will typically inline these trivial accessors, making record pattern matching as fast as manual field access.
Related Topics
- Switch Expressions — the modern switch syntax that pattern matching builds on top of.
- Sealed Classes — sealed hierarchies enable exhaustive pattern switches with no
defaultneeded. - Records — records are the natural target of record patterns and deconstruction.
- instanceof Operator — the classic type check that pattern matching replaces and extends.
- Java 21 LTS Features — the full list of what landed in the Java 21 release.
- Modern Java (9–21) — overview of all major language features since Java 9.