Record Patterns

Deconstruct records, including nested ones, directly in instanceof and switch, and know how record patterns treat null.

Syntaxif (obj instanceof Point(int x, var y)) { ... } case Line(Point(var x1, var y1), _) -> ...

A record pattern matches a record and pulls its components out in one step. It became a standard feature in Java 21.

if (o instanceof Point(int x, int y)) {
    // x and y are bound here
}

The pattern checks that o is a Point, calls its accessors, and binds each result to a new variable. Each component position holds another pattern, so you can match a type, write var to let the compiler infer it, or nest a further record pattern to reach inside a component:

if (item instanceof LineItem(var sku, var qty, Money(var pence, var currency))) { ... }

Ignoring components

Since Java 22 you can write _ for a component you do not need, as in case CreateProject(var name, _). It is clearer than naming a variable you never read, and keeps patterns readable as records grow.

In switch

Record patterns are most useful as case labels over a sealed type. Each case names the variant and its fields together, so the handler for a command or event reads like its definition, and the compiler still checks that every permitted record is covered.

Where this pays off in a back end

  • Handling commands from a queue or an RPC layer, where each command is a record.
  • Reading nested DTOs, such as an order with a customer and an address, without a chain of accessor calls.
  • Matching two values at once, such as the current state and an incoming action in a workflow, by wrapping them in a small record.

Rules that catch people out

A record pattern never matches null, and that includes nested ones: if line.start() is null, Line(Point(var x, var y), var end) does not match. A type pattern or var in a component position is different. When it covers the component's declared type it matches whatever the component holds, null included. So Box(String label) matches a Box whose label is null, and label is then null.

Patterns call the accessor methods. If you have overridden an accessor to return a copy or a formatted value, that is what the pattern binds.

For a generic record such as Transition<S, A>, var components are inferred from the type being matched, so you do not repeat the type arguments.

Record patterns deconstruct only records. For ordinary classes, match with a type pattern and call the getters.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Dispatch commands read from a message queue by deconstructing each command record in a switch.
  • Pull the currency and amount out of a nested order DTO in one pattern instead of a chain of accessor calls.
  • Implement a document approval workflow by matching the current state and the requested action together.

More examples

Handling commands with _ and guards

Each case reads like the command's definition, and _ marks the owner email as deliberately unused. The guarded DeleteProject case must come before the unguarded one, which also keeps the switch exhaustive.

Example · java
sealed interface Command {}
record CreateProject(String name, String ownerEmail) implements Command {}
record RenameProject(long id, String newName) implements Command {}
record DeleteProject(long id, boolean hard) implements Command {}

String handle(Command command) {
    return switch (command) {
        case CreateProject(var name, _) -> "created " + name;
        case RenameProject(var id, var newName) -> "renamed " + id + " to " + newName;
        case DeleteProject(var id, var hard) when hard -> "purged " + id;
        case DeleteProject(var id, _) -> "archived " + id;
    };
}

void main() {
    List<Command> commands = List.of(
            new CreateProject("billing", "[email protected]"),
            new RenameProject(7, "invoicing"),
            new DeleteProject(7, true),
            new DeleteProject(8, false));
    for (Command c : commands) {
        IO.println(handle(c));
    }
}

// Output:
// created billing
// renamed 7 to invoicing
// purged 7
// archived 8

How null components behave

String label covers the component's type, so it matches null and binds null. The nested Point pattern needs a real Point, so a null start fails the whole match while var start accepts it.

Example · java
record Point(int x, int y) {}
record Line(Point start, Point end) {}
record Box(String label) {}

void main() {
    Object box = new Box(null);
    if (box instanceof Box(String label)) {
        IO.println("Box matched, label = " + label);
    }

    Object line = new Line(null, new Point(3, 4));
    if (line instanceof Line(Point(var x, var y), var end)) {
        IO.println("start at " + x + "," + y);
    } else {
        IO.println("Line with a null start did not match");
    }
    if (line instanceof Line(var start, Point(var x, var y))) {
        IO.println("end at " + x + "," + y + ", start = " + start);
    }
}

// Output:
// Box matched, label = null
// Line with a null start did not match
// end at 3,4, start = null

Matching a pair of values in a workflow

The raw-looking Transition(var s, var a) is inferred as Transition<State, Action>, so s and a have the enum types in the guards. An invalid move becomes an exception your API can turn into a 409.

Example · java
enum State { DRAFT, SUBMITTED, APPROVED }
enum Action { SUBMIT, APPROVE, REJECT }
record Transition<S, A>(S state, A action) {}

State next(State state, Action action) {
    return switch (new Transition<>(state, action)) {
        case Transition(var s, var a) when s == State.DRAFT && a == Action.SUBMIT -> State.SUBMITTED;
        case Transition(var s, var a) when s == State.SUBMITTED && a == Action.APPROVE -> State.APPROVED;
        case Transition(var s, var a) when s == State.SUBMITTED && a == Action.REJECT -> State.DRAFT;
        case Transition(var s, var a) -> throw new IllegalStateException("cannot " + a + " when " + s);
    };
}

void main() {
    State state = State.DRAFT;
    for (Action a : List.of(Action.SUBMIT, Action.REJECT, Action.SUBMIT, Action.APPROVE)) {
        state = next(state, a);
        IO.println(a + " -> " + state);
    }
    try {
        next(State.APPROVED, Action.SUBMIT);
    } catch (IllegalStateException e) {
        IO.println(e.getMessage());
    }
}

// Output:
// SUBMIT -> SUBMITTED
// REJECT -> DRAFT
// SUBMIT -> SUBMITTED
// APPROVE -> APPROVED
// cannot SUBMIT when APPROVED

Discussion

  • Be the first to comment on this lesson.