What record patterns add to pattern matching
Record patterns (finalized in Java 21, JEP 440) extend Java's pattern matching to allow inline deconstruction of record components. Instead of testing a type, casting, and then calling accessors, you state the shape of the data you expect and bind each component to a variable in one expression.
Before record patterns:
Object obj = new Point(3, 4);
if (obj instanceof Point p) {
int x = p.x(); // accessor
int y = p.y(); // accessor
System.out.println(x + ", " + y);
}
With record patterns (Java 21):
if (obj instanceof Point(int x, int y)) {
System.out.println(x + ", " + y); // x and y bound directly
}
The type is checked and the components are destructured in a single expression. The
pattern Point(int x, int y) reads like a constructor call in reverse — "match a
Point and extract its x and y."
Record patterns in switch
Record patterns are most powerful in switch expressions over sealed types:
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Triangle(double base, double h) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
case Triangle(double b, double h) -> 0.5 * b * h;
};
}
The compiler verifies exhaustiveness (all three Shape subtypes are covered), and the
components are bound inline — no instanceof checks, no casts, no accessor chains.
Nested record patterns
Components that are themselves records can be destructured inline, eliminating deep accessor chains:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
Object obj = new Line(new Point(0, 0), new Point(3, 4));
// Accessor chain — verbose:
if (obj instanceof Line l) {
int x1 = l.start().x(), y1 = l.start().y();
int x2 = l.end().x(), y2 = l.end().y();
}
// Nested record pattern — concise:
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
double length = Math.sqrt(Math.pow(x2-x1, 2) + Math.pow(y2-y1, 2));
System.out.println("length: " + length); // 5.0
}
Nesting is arbitrarily deep. This is especially clean for recursive data structures:
sealed interface Expr permits Num, Add, Mul {}
record Num(int v) implements Expr {}
record Add(Expr l, Expr r) implements Expr {}
record Mul(Expr l, Expr r) implements Expr {}
int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
// eval(Mul(Num(2), Add(Num(3), Num(4)))) = 2 * (3 + 4) = 14
Using var to infer component types
var can be used in a record pattern to let the compiler infer the component type:
record Pair<A, B>(A first, B second) {}
Object obj = new Pair<>("hello", 42);
if (obj instanceof Pair(var first, var second)) {
System.out.println(first + " / " + second);
// first is Object (due to erasure), second is Object
}
// If you know the concrete types:
if (obj instanceof Pair(String first, Integer second)) {
System.out.println(first.toUpperCase() + " " + (second * 2));
// compiler may warn: unchecked cast due to erasure
}
For generic records, var avoids unchecked warnings. Use explicit types only when
you're certain of the runtime types.
Guarded patterns — when clause
when guards attach a boolean condition to a record pattern:
String classify(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == 0 && y == 0 -> "origin";
case Point(int x, int y) when x == 0 -> "y-axis";
case Point(int x, int y) when y == 0 -> "x-axis";
case Point(int x, int y) -> "quadrant " + quadrant(x, y);
default -> "not a point";
};
}
Cases are evaluated top-to-bottom; the when guard is checked only after the type and
structure match. More specific guards must come before more general ones.
Scope and null behaviour
Variables bound in a record pattern follow the same flow-sensitive scope rules as all pattern variables:
- In scope in the true branch and
&&chains. - Not in scope in the false/else branch or
||chains. - Never bound for null —
instanceofreturns false for null regardless of pattern.
Object obj = null;
System.out.println(obj instanceof Point(int x, int y)); // false — no NPE
// Handle null explicitly in switch:
switch (obj) {
case null -> System.out.println("null");
case Point(int x, int y) -> System.out.println(x + ", " + y);
default -> System.out.println("other");
}
Exhaustiveness and sealed types
Exhaustiveness checking covers which subtypes are handled, not which component values. Adding a new subtype to a sealed interface breaks every exhaustive switch that doesn't handle it — the compiler guides you to every site that needs updating:
sealed interface Event permits Login, Logout {}
record Login(String userId, Instant at) implements Event {}
record Logout(String userId, Instant at) implements Event {}
void audit(Event e) {
switch (e) {
case Login(String id, Instant t) -> log("login", id, t);
case Logout(String id, Instant t) -> log("logout", id, t);
}
// Adding 'PasswordChange' to Event → compile error here
}
Replacing the Visitor pattern
Record patterns with sealed types are the idiomatic modern replacement for the Visitor pattern. The Visitor existed to add operations on a closed type hierarchy without modifying each class — sealed + switch achieves the same without the ceremony:
// Visitor — requires: interface ExprVisitor<R>, accept() in each Expr, ExprVisitorImpl
// ~60 lines for a 3-node tree
// Record patterns — zero infrastructure:
int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
String prettyPrint(Expr e) {
return switch (e) {
case Num(int v) -> String.valueOf(v);
case Add(Expr l, Expr r) -> "(" + prettyPrint(l) + " + " + prettyPrint(r) + ")";
case Mul(Expr l, Expr r) -> "(" + prettyPrint(l) + " × " + prettyPrint(r) + ")";
};
}
Adding a new operation (eval, prettyPrint, typecheck) means writing one new
function. Adding a new type (e.g., Div) breaks every existing switch — the compiler
tells you exactly where to add the new case.
When to use record patterns vs accessors
Use a record pattern when:
- You need most or all components of a record.
- You're dispatching over multiple record types in a switch.
- Components are nested records and accessor chains would be verbose.
Use accessors when:
- You only need one or two components of a large record.
- The binding variable name from the outer
instanceofis already clear.
// Only need x — accessor is simpler:
if (obj instanceof Point p) {
process(p.x());
}
// Need both — record pattern wins:
if (obj instanceof Point(int x, int y)) {
process(x, y);
}
Recap
Record patterns (Java 21) destructure record components inline in instanceof and
switch, eliminating the accessor boilerplate when you need multiple values. They
support nesting (components that are themselves records), var for type
inference, and when guards for component-level conditions. Null never matches a
record pattern — handle it with case null in switch. Exhaustiveness checking in switch
covers which sealed subtypes are handled; component-value exhaustiveness uses when plus
a catch-all case. Combined with sealed interfaces, record patterns replace the
Visitor pattern: adding a new operation is a new function; adding a new type variant
causes compile errors at every exhaustive switch that needs updating.