Record Patterns (JEP 440): Destructuring Records with Power and Precision
Records Recap
Records (Java 16, JEP 395) are transparent carriers of immutable data:
record Point(int x, int y) {}
record ColoredPoint(Point point, String color) {}
record Line(Point start, Point end) {}
The compiler generates a constructor, accessor methods (x(), y()), equals, hashCode, and toString. Before Java 21, accessing record components required calling accessor methods:
Object obj = new ColoredPoint(new Point(3, 4), "red");
if (obj instanceof ColoredPoint cp) {
int x = cp.point().x(); // manual access
int y = cp.point().y(); // manual access
String color = cp.color(); // manual access
System.out.println(color + " at " + x + "," + y);
}
Record patterns eliminate this chaining by destructuring in the pattern itself.
Basic Record Patterns
// Java 21 — destructure in the pattern
if (obj instanceof ColoredPoint(Point point, String color)) {
System.out.println(color + " at " + point.x() + "," + point.y());
}
The pattern ColoredPoint(Point point, String color) does three things simultaneously:
- Tests that
objis aColoredPoint - Extracts the
pointcomponent into aPointvariable - Extracts the
colorcomponent into aStringvariable
Component names in the pattern are your variable names — they don’t have to match the record component names (though they should for clarity).
Nested Record Patterns
Patterns can be nested arbitrarily deep:
record Point(int x, int y) {}
record ColoredPoint(Point point, String color) {}
Object obj = new ColoredPoint(new Point(3, 4), "blue");
// Nested pattern — extract x and y directly
if (obj instanceof ColoredPoint(Point(int x, int y), String color)) {
System.out.println(color + " at " + x + "," + y);
// x=3, y=4, color="blue"
}
flowchart TD
Pattern["ColoredPoint(Point(int x, int y), String color)"]
Step1["Test: obj instanceof ColoredPoint?"]
Step2["Destructure: extract .point() as Point(int x, int y)\nextract .color() as color"]
Step3["Nested test: .point() instanceof Point?"]
Step4["Destructure: extract .x() as x\nextract .y() as y"]
Bindings["Bound variables: x=3, y=4, color='blue'"]
Pattern --> Step1 --> Step2 --> Step3 --> Step4 --> Bindings
The match fails if any level of the nested pattern fails. If point() returns null, the entire pattern fails gracefully — no NullPointerException.
Record Patterns in switch
The real power comes from combining record patterns with switch:
sealed interface Shape permits Circle, Rectangle, Line {}
record Circle(Point center, double radius) implements Shape {}
record Rectangle(Point topLeft, Point bottomRight) implements Shape {}
record Line(Point start, Point end) implements Shape {}
static String describe(Shape shape) {
return switch (shape) {
case Circle(Point(int cx, int cy), double r) ->
"Circle at (%d,%d) radius %.1f".formatted(cx, cy, r);
case Rectangle(Point(int x1, int y1), Point(int x2, int y2)) ->
"Rectangle from (%d,%d) to (%d,%d)".formatted(x1, y1, x2, y2);
case Line(Point(int sx, int sy), Point(int ex, int ey)) ->
"Line from (%d,%d) to (%d,%d)".formatted(sx, sy, ex, ey);
};
}
Combining Record Patterns with Guards
static String quadrant(Shape shape) {
return switch (shape) {
case Circle(Point(int x, int y), double r)
when x > 0 && y > 0 -> "Circle in Quadrant I";
case Circle(Point(int x, int y), double r)
when x < 0 && y > 0 -> "Circle in Quadrant II";
case Circle(Point(int x, int y), double r)
-> "Circle in other quadrant (" + x + "," + y + ")";
case Rectangle r -> "Rectangle";
case Line l -> "Line";
};
}
The when clause can reference any variables bound by the record pattern.
Record Patterns with Generic Records
Generic records work with record patterns — the compiler infers the type:
record Pair<A, B>(A first, B second) {}
record Box<T>(T value) {}
Object obj = new Pair<>("hello", 42);
if (obj instanceof Pair<String, Integer>(String s, Integer n)) {
System.out.println(s + " — " + n); // "hello — 42"
}
// Works in switch too
static <T> String describe(Box<T> box) {
return switch (box) {
case Box<T>(null) -> "empty box";
case Box<T>(var v) -> "box with: " + v;
};
}
Deep Nesting Example — Order Processing
record Address(String street, String city, String country) {}
record Customer(String name, Address address) {}
record Order(String id, Customer customer, double total) {}
static String formatOrderSummary(Object obj) {
return switch (obj) {
case Order(
String id,
Customer(String name, Address(_, String city, String country)),
double total
) when total > 1000 ->
"HIGH VALUE: Order %s for %s in %s, %s — $%.2f"
.formatted(id, name, city, country, total);
case Order(
String id,
Customer(String name, Address(_, String city, String country)),
double total
) ->
"Order %s for %s in %s, %s — $%.2f"
.formatted(id, name, city, country, total);
default -> "Unknown object: " + obj;
};
}
Note: _ is the unnamed pattern (JEP 443 preview) — used here to skip street. Without preview features, use a named variable even if unused:
Address(String _street, String city, String country)
// or
Address(var ignored, String city, String country)
How the Compiler Checks Record Patterns
The compiler knows the exact component types from the record declaration. It verifies:
- Component count: Pattern must have exactly as many sub-patterns as record components
- Component type compatibility: Each sub-pattern type must be compatible with the corresponding component type
- Exhaustiveness: In sealed type switches, all record subtypes must be covered
record Pair(String first, int second) {}
// Compile error — wrong component count
if (obj instanceof Pair(String s)) { ... } // ERROR: Pair has 2 components
// Compile error — incompatible type
if (obj instanceof Pair(Integer i, int n)) { ... } // ERROR: first is String not Integer
// OK — widening is allowed
if (obj instanceof Pair(Object o, int n)) { ... } // OK: String is Object
Record Patterns vs Manual Extraction
| Approach | Code | Verbosity | Null safety |
|---|---|---|---|
| Manual accessors | cp.point().x() | High | NullPointerException risk |
| instanceof + accessors | instanceof ColoredPoint cp then cp.point().x() | Medium | NPE on null point |
| Record pattern | ColoredPoint(Point(int x, int y), _) | Low | Pattern fails gracefully |
Practical Pattern: JSON-Like AST Processing
sealed interface JsonNode
permits JsonNull, JsonBool, JsonNumber, JsonString, JsonArray, JsonObject {}
record JsonNull() implements JsonNode {}
record JsonBool(boolean value) implements JsonNode {}
record JsonNumber(double value) implements JsonNode {}
record JsonString(String value) implements JsonNode {}
record JsonArray(List<JsonNode> elements) implements JsonNode {}
record JsonObject(Map<String, JsonNode> fields) implements JsonNode {}
static String toDisplayString(JsonNode node) {
return switch (node) {
case JsonNull() -> "null";
case JsonBool(boolean v) -> String.valueOf(v);
case JsonNumber(double v) -> String.valueOf(v);
case JsonString(String v) -> "\"" + v + "\"";
case JsonArray(var elems) ->
"[" + elems.stream()
.map(JavaParser::toDisplayString)
.collect(joining(", ")) + "]";
case JsonObject(var fields) ->
"{" + fields.entrySet().stream()
.map(e -> "\"" + e.getKey() + "\": " + toDisplayString(e.getValue()))
.collect(joining(", ")) + "}";
};
}
Key Takeaways
- Record patterns destructure record components in the pattern itself — no accessor chains needed
- Patterns can be nested arbitrarily:
Outer(Inner(int x, int y), String s)bindsx,y, andsdirectly - A nested pattern that fails (e.g., inner component is null) causes the whole pattern to fail — no NPE
- Record patterns work in both
instanceofexpressions andswitcharms - Combine with
whenguards for conditional destructuring - The compiler verifies component count and type compatibility at compile time
- Generic record patterns require type arguments:
Box<String>(String s)
Next: Sequenced Collections (JEP 431) — the new unified API for accessing first and last elements across all ordered Java collections.