Part 4 of 15

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:

  1. Tests that obj is a ColoredPoint
  2. Extracts the point component into a Point variable
  3. Extracts the color component into a String variable

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:

  1. Component count: Pattern must have exactly as many sub-patterns as record components
  2. Component type compatibility: Each sub-pattern type must be compatible with the corresponding component type
  3. 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

ApproachCodeVerbosityNull safety
Manual accessorscp.point().x()HighNullPointerException risk
instanceof + accessorsinstanceof ColoredPoint cp then cp.point().x()MediumNPE on null point
Record patternColoredPoint(Point(int x, int y), _)LowPattern 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) binds x, y, and s directly
  • A nested pattern that fails (e.g., inner component is null) causes the whole pattern to fail — no NPE
  • Record patterns work in both instanceof expressions and switch arms
  • Combine with when guards 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.