Record Patterns
Deconstruct records, including nested ones, directly in instanceof and switch, and know how record patterns treat null.
if (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
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.
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 8How 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.
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 = nullMatching 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.
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