Java records, introduced in Java 16, gave us a concise way to define immutable data carriers. But accessing the data inside a record still required calling accessor methods one at a time. Record Patterns, finalized in Java 21 (JEP 440), change that by letting you deconstruct a record directly in a pattern, extracting all its components in a single expression.
Think of it like the difference between receiving a wrapped gift and having to unwrap it yourself versus the gift magically appearing unwrapped in your hands. Record patterns let Java unwrap the record for you and place each component directly into named variables.
Here is the core idea in one comparison:
record Point(int x, int y) {}
// Without record patterns: extract fields manually
Object obj = new Point(3, 7);
if (obj instanceof Point p) {
int x = p.x(); // manual extraction
int y = p.y(); // manual extraction
System.out.println("Point at (" + x + ", " + y + ")");
}
// With record patterns: fields extracted automatically
if (obj instanceof Point(int x, int y)) {
// x and y are already available -- no accessor calls needed
System.out.println("Point at (" + x + ", " + y + ")");
}
The savings might look modest for a two-field record, but the power becomes clear when you have nested records, records with many fields, or when you combine record patterns with switch expressions and sealed types. Across a codebase, the reduction in boilerplate is substantial.
Record patterns work in two places:
instanceof expressions — if (obj instanceof Point(int x, int y))switch cases — case Point(int x, int y) ->| Feature | JEP | Java Version | Status |
|---|---|---|---|
| Records | JEP 395 | Java 16 | Final |
| Pattern Matching for instanceof | JEP 394 | Java 16 | Final |
| Record Patterns (preview) | JEP 405 | Java 19 | Preview |
| Record Patterns (second preview) | JEP 432 | Java 20 | Preview |
| Record Patterns | JEP 440 | Java 21 | Final |
| Pattern Matching for switch | JEP 441 | Java 21 | Final |
Record patterns and pattern matching for switch were finalized together in Java 21, and they were designed to work hand-in-hand. This post focuses on record patterns specifically — how they work, how to nest them, how to use them with generics and sealed types, and when they make your code significantly better.
A record pattern matches a record type and simultaneously extracts its components into variables. The syntax mirrors the record’s constructor: you write the record name followed by the component types and names in parentheses.
The general form is: RecordType(ComponentType1 name1, ComponentType2 name2, ...)
// Define some records
record Point(int x, int y) {}
record Name(String first, String last) {}
record Email(String address, boolean verified) {}
// Basic record pattern with instanceof
public void demonstrateBasics(Object obj) {
// Deconstruct a Point
if (obj instanceof Point(int x, int y)) {
System.out.println("x = " + x + ", y = " + y);
double distance = Math.sqrt(x * x + y * y);
System.out.println("Distance from origin: " + distance);
}
// Deconstruct a Name
if (obj instanceof Name(String first, String last)) {
System.out.println("Full name: " + first + " " + last);
System.out.println("Initials: " + first.charAt(0) + "." + last.charAt(0) + ".");
}
// Deconstruct an Email
if (obj instanceof Email(String address, boolean verified)) {
if (verified) {
System.out.println("Verified email: " + address);
} else {
System.out.println("Unverified email: " + address + " -- please verify");
}
}
}
When the compiler sees instanceof Point(int x, int y), it generates code that:
Pointpoint.x() and assigns the result to xpoint.y() and assigns the result to yx and y available in the scope where the pattern matchedThe component types in the pattern must match the record’s component types. You cannot write Point(String x, String y) when Point has int components — the compiler will reject it.
record Temperature(double value, String unit) {}
public String classifyTemperature(Object obj) {
if (obj instanceof Temperature(double value, String unit)) {
double celsius = unit.equals("F") ? (value - 32) * 5.0 / 9.0 : value;
if (celsius < 0) return "freezing";
if (celsius < 15) return "cold";
if (celsius < 25) return "comfortable";
if (celsius < 35) return "warm";
return "hot";
}
return "not a temperature";
}
// Record patterns work in negative conditions too
public void processNonPoint(Object obj) {
if (!(obj instanceof Point(int x, int y))) {
System.out.println("Not a point, handling differently");
return;
}
// x and y are in scope here because the negative check returned
System.out.println("Processing point at (" + x + ", " + y + ")");
}
Notice the flow scoping in the second example. When you negate the pattern and return early, the pattern variables (x and y) are available after the if block because the compiler knows the object must be a Point in that code path.
Record patterns become even more powerful inside switch expressions. You can deconstruct records, combine them with guard clauses using when, and match multiple record types in a single switch. The switch expression form means the whole thing returns a value.
// Record patterns in switch expressions
record Circle(double radius) {}
record Rectangle(double width, double height) {}
record Triangle(double base, double height) {}
public String describeShape(Object shape) {
return switch (shape) {
case Circle(double r) when r <= 0
-> "invalid circle: radius must be positive";
case Circle(double r) when r < 1
-> "tiny circle with radius " + r;
case Circle(double r)
-> "circle with radius " + r + " and area " + String.format("%.2f", Math.PI * r * r);
case Rectangle(double w, double h) when w == h
-> "square with side " + w;
case Rectangle(double w, double h)
-> "rectangle " + w + " x " + h;
case Triangle(double b, double h)
-> "triangle with base " + b + " and height " + h;
default -> "unknown shape: " + shape;
};
}
The when clause in a switch case can reference the variables extracted by the record pattern. This is extremely powerful for building validation and routing logic.
record Order(String id, String status, double total, String customerId) {}
public String processOrder(Object obj) {
return switch (obj) {
case Order(var id, var status, var total, var cust) when status.equals("PENDING") && total > 10000
-> "High-value order " + id + " from " + cust + " requires manual approval";
case Order(var id, var status, var total, var cust) when status.equals("PENDING")
-> "Auto-approving order " + id + " for $" + total;
case Order(var id, var status, var total, var cust) when status.equals("SHIPPED")
-> "Order " + id + " is in transit to customer " + cust;
case Order(var id, var status, var total, var cust) when status.equals("DELIVERED")
-> "Order " + id + " delivered. Total: $" + total;
case Order(var id, var status, var total, var cust) when status.equals("CANCELLED")
-> "Order " + id + " was cancelled. Refund $" + total + " to " + cust;
case Order(var id, var status, var total, var cust)
-> "Order " + id + " in unknown status: " + status;
default -> "Not an order";
};
}
// Multiple record types in one switch
record Deposit(String accountId, double amount) {}
record Withdrawal(String accountId, double amount) {}
record Transfer(String fromAccount, String toAccount, double amount) {}
public String processTransaction(Object transaction) {
return switch (transaction) {
case Deposit(var acct, var amt) when amt > 10000
-> "Large deposit to " + acct + ": $" + amt + " -- flagged for review";
case Deposit(var acct, var amt)
-> "Deposited $" + amt + " to " + acct;
case Withdrawal(var acct, var amt) when amt > 5000
-> "Large withdrawal from " + acct + ": $" + amt + " -- requires approval";
case Withdrawal(var acct, var amt)
-> "Withdrew $" + amt + " from " + acct;
case Transfer(var from, var to, var amt) when from.equals(to)
-> "Error: cannot transfer to the same account";
case Transfer(var from, var to, var amt)
-> "Transferred $" + amt + " from " + from + " to " + to;
case null -> "Error: null transaction";
default -> "Unknown transaction type";
};
}
Each case deconstructs the record and applies a guard condition in a single, readable line. The switch reads like a decision table: for this type with these conditions, do this. Compare that to nested if-else chains with manual accessor calls, and the readability improvement is dramatic.
This is where record patterns really set themselves apart. When a record contains other records as components, you can deconstruct them all at once in a single pattern. Instead of extracting the outer record and then manually calling accessors on the inner records, you write a nested pattern that reaches straight into the inner structure.
Think of it like Russian nesting dolls (matryoshkas). Without nested patterns, you open the outer doll, take out the middle doll, open it, take out the inner doll. With nested patterns, you describe the shape of all the dolls at once, and Java opens them all for you simultaneously.
// Define a hierarchy of records
record Point(int x, int y) {}
record Line(Point start, Point end) {}
record Triangle(Point a, Point b, Point c) {}
record ColoredPoint(Point point, String color) {}
record BoundingBox(Point topLeft, Point bottomRight) {}
// Nested deconstruction: one level deep
public String describeLine(Object obj) {
return switch (obj) {
case Line(Point(var x1, var y1), Point(var x2, var y2))
-> String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
default -> "not a line";
};
}
// Without nested patterns, the same code looks like this:
public String describeLineOld(Object obj) {
if (obj instanceof Line line) {
Point start = line.start();
Point end = line.end();
int x1 = start.x();
int y1 = start.y();
int x2 = end.x();
int y2 = end.y();
return String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
}
return "not a line";
}
// Two levels deep: Triangle with three Points
public double triangleArea(Object obj) {
return switch (obj) {
case Triangle(Point(var x1, var y1), Point(var x2, var y2), Point(var x3, var y3))
-> Math.abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0);
default -> 0.0;
};
}
// Nested with mixed types
public String describeColoredPoint(Object obj) {
return switch (obj) {
case ColoredPoint(Point(var x, var y), var color) when color.equals("red")
-> "Red point at (" + x + ", " + y + ") -- danger zone!";
case ColoredPoint(Point(var x, var y), var color)
-> color + " point at (" + x + ", " + y + ")";
default -> "not a colored point";
};
}
// BoundingBox with guards on nested components
public String classifyBoundingBox(Object obj) {
return switch (obj) {
case BoundingBox(Point(var x1, var y1), Point(var x2, var y2))
when x1 == x2 || y1 == y2
-> "degenerate bounding box (zero area)";
case BoundingBox(Point(var x1, var y1), Point(var x2, var y2))
when Math.abs(x2 - x1) == Math.abs(y2 - y1)
-> "square bounding box: side " + Math.abs(x2 - x1);
case BoundingBox(Point(var x1, var y1), Point(var x2, var y2))
-> "bounding box: " + Math.abs(x2 - x1) + " x " + Math.abs(y2 - y1);
default -> "not a bounding box";
};
}
Nesting can go as deep as your record hierarchy. Here is a three-level example:
record Street(String name, String number) {}
record City(String name, String state) {}
record Address(Street street, City city, String zip) {}
record Customer(String name, Address address) {}
// Three levels deep: Customer -> Address -> Street/City
public String formatAddress(Object obj) {
return switch (obj) {
case Customer(var name, Address(Street(var st, var num), City(var city, var state), var zip))
-> String.format("%s\n%s %s\n%s, %s %s", name, num, st, city, state, zip);
default -> "not a customer";
};
}
// Usage:
var customer = new Customer("Alice Smith",
new Address(
new Street("Main St", "123"),
new City("Portland", "OR"),
"97201"
)
);
System.out.println(formatAddress(customer));
// Output:
// Alice Smith
// 123 Main St
// Portland, OR 97201
Without nested record patterns, extracting those six values from a Customer would require six lines of accessor calls. The nested pattern does it in one line. This is not just about fewer characters -- it is about expressing the structure of your data directly in the code.
Readability tip: When nested patterns get wide, break them across lines for clarity. The compiler does not care about whitespace, but your teammates will thank you.
You do not always need to spell out the component types in a record pattern. Java lets you use var to let the compiler infer the type for you. This reduces noise and keeps patterns concise, especially when the types are obvious from context.
record Point(int x, int y) {}
record Employee(String name, String department, int salary) {}
record Pair(Object first, Object second) {}
// Explicit types
if (obj instanceof Point(int x, int y)) {
System.out.println(x + ", " + y);
}
// var -- compiler infers int x, int y
if (obj instanceof Point(var x, var y)) {
System.out.println(x + ", " + y);
}
// var is especially useful with long type names or nested records
record OrderLine(String productId, String productName, int quantity, double unitPrice) {}
record Order(String orderId, List lines, double total) {}
// Explicit types (verbose)
if (obj instanceof Order(String orderId, List lines, double total)) {
// ...
}
// var (clean)
if (obj instanceof Order(var orderId, var lines, var total)) {
// ...
}
// var in switch cases
public double calculateDiscount(Object obj) {
return switch (obj) {
case Employee(var name, var dept, var salary) when dept.equals("Sales") && salary > 100000
-> 0.15;
case Employee(var name, var dept, var salary) when dept.equals("Sales")
-> 0.10;
case Employee(var name, var dept, var salary)
-> 0.05;
default -> 0.0;
};
}
You can mix var and explicit types in the same pattern. Use explicit types for components where the type is important to the reader, and var for the rest.
record HttpResponse(int statusCode, String contentType, byte[] body) {}
// Mix var and explicit types
if (obj instanceof HttpResponse(int code, var contentType, var body)) {
// 'code' is explicitly int -- important for the reader
// contentType and body types are obvious from the record definition
if (code == 200) {
process(contentType, body);
}
}
// Nested record patterns with var
record Point(int x, int y) {}
record Line(Point start, Point end) {}
// All var -- concise
if (obj instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
double length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
System.out.println("Length: " + length);
}
Use var |
Use Explicit Types |
|---|---|
| Record has few components with obvious types | Types are not obvious from the record name |
| You are focused on the values, not the types | You want to document the type for readers |
| The pattern is already long (nested records) | Generic records where the type parameter matters |
| Component type is a well-known type (String, int, etc.) | Component is an interface with multiple implementations |
Records can be generic, and record patterns work with generic records. The compiler uses type inference to determine the component types, so you do not need to specify generic type arguments in the pattern itself.
// Generic records record Pair(A first, B second) {} record Result(T value, boolean success, String message) {} record Wrapper (T content, Map metadata) {} // Pattern matching with generic records public void processGeneric(Object obj) { // The compiler infers generic types from the actual object if (obj instanceof Pair(var first, var second)) { System.out.println("Pair: " + first + " and " + second); } if (obj instanceof Result(var value, var success, var message)) { if (success) { System.out.println("Success: " + value); } else { System.out.println("Failure: " + message); } } } // Generic records in switch public String describeResult(Object obj) { return switch (obj) { case Result(var value, var success, var msg) when success && value instanceof String s -> "String result: " + s; case Result(var value, var success, var msg) when success && value instanceof Integer i -> "Integer result: " + i; case Result(var value, var success, var msg) when success -> "Result: " + value; case Result(var value, var success, var msg) -> "Failed: " + msg; default -> "not a result"; }; }
When you use var with generic record patterns, the compiler infers the erased type. At runtime, generic type information is not available due to type erasure, so the pattern variable will have the erased type (usually Object for unbounded type parameters).
record Box(T content) {} record Either (L left, R right, boolean isLeft) {} // var infers the erased type public void processBox(Object obj) { if (obj instanceof Box(var content)) { // content is Object at runtime (type erasure) // But we can add further type checks if (content instanceof String s) { System.out.println("Box contains string: " + s); } else if (content instanceof Integer i) { System.out.println("Box contains integer: " + i); } } } // Either type with record patterns public String processEither(Object obj) { return switch (obj) { case Either(var left, var right, var isLeft) when isLeft -> "Left: " + left; case Either(var left, var right, var isLeft) -> "Right: " + right; default -> "not an either"; }; } // Generic record with bounded type parameter record NumberBox (T value, String label) {} public String describeNumberBox(Object obj) { return switch (obj) { case NumberBox(var value, var label) when value.doubleValue() > 100 -> label + " is large: " + value; case NumberBox(var value, var label) when value.doubleValue() < 0 -> label + " is negative: " + value; case NumberBox(var value, var label) -> label + ": " + value; default -> "not a number box"; }; // Because T extends Number, 'value' is inferred as Number (the bound), // so doubleValue() is available without casting }
Generic record patterns work seamlessly with the rest of Java's type system. The key insight is that var gives you the erased type, and you can combine record patterns with type patterns (nested instanceof checks) to narrow the type when you need specific operations.
Record patterns combined with sealed types create the most type-safe dispatching mechanism Java has ever had. When your sealed interface is implemented by records, you get exhaustive pattern matching with automatic deconstruction -- the compiler verifies you handled every case, and each case gives you direct access to the record's fields.
// Sealed interface with record implementations
public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double a, double b, double c) implements Shape {}
// Exhaustive switch with record patterns -- no default needed
public double area(Shape shape) {
return switch (shape) {
case Circle(var r) -> Math.PI * r * r;
case Rectangle(var w, var h) -> w * h;
case Triangle(var a, var b, var c) -> {
double s = (a + b + c) / 2;
yield Math.sqrt(s * (s - a) * (s - b) * (s - c)); // Heron's formula
}
};
}
public double perimeter(Shape shape) {
return switch (shape) {
case Circle(var r) -> 2 * Math.PI * r;
case Rectangle(var w, var h) -> 2 * (w + h);
case Triangle(var a, var b, var c) -> a + b + c;
};
}
public String describe(Shape shape) {
return switch (shape) {
case Circle(var r)
-> String.format("Circle: radius=%.2f, area=%.2f", r, Math.PI * r * r);
case Rectangle(var w, var h) when w == h
-> String.format("Square: side=%.2f, area=%.2f", w, w * h);
case Rectangle(var w, var h)
-> String.format("Rectangle: %.2f x %.2f, area=%.2f", w, h, w * h);
case Triangle(var a, var b, var c) when a == b && b == c
-> String.format("Equilateral triangle: side=%.2f", a);
case Triangle(var a, var b, var c)
-> String.format("Triangle: sides %.2f, %.2f, %.2f", a, b, c);
};
}
// Multi-level sealed hierarchy
public sealed interface Expr permits Literal, UnaryOp, BinaryOp {}
public record Literal(double value) implements Expr {}
public sealed interface UnaryOp extends Expr permits Negate, Sqrt {}
public record Negate(Expr operand) implements UnaryOp {}
public record Sqrt(Expr operand) implements UnaryOp {}
public sealed interface BinaryOp extends Expr permits Add, Subtract, Multiply, Divide {}
public record Add(Expr left, Expr right) implements BinaryOp {}
public record Subtract(Expr left, Expr right) implements BinaryOp {}
public record Multiply(Expr left, Expr right) implements BinaryOp {}
public record Divide(Expr left, Expr right) implements BinaryOp {}
// Exhaustive evaluation -- compiler verifies completeness across the entire hierarchy
public double evaluate(Expr expr) {
return switch (expr) {
case Literal(var v) -> v;
case Negate(var e) -> -evaluate(e);
case Sqrt(var e) -> Math.sqrt(evaluate(e));
case Add(var l, var r) -> evaluate(l) + evaluate(r);
case Subtract(var l, var r) -> evaluate(l) - evaluate(r);
case Multiply(var l, var r) -> evaluate(l) * evaluate(r);
case Divide(var l, var r) -> {
double divisor = evaluate(r);
if (divisor == 0) throw new ArithmeticException("Division by zero");
yield evaluate(l) / divisor;
}
};
}
// Pretty printer -- also exhaustive
public String prettyPrint(Expr expr) {
return switch (expr) {
case Literal(var v) -> String.valueOf(v);
case Negate(var e) -> "-(" + prettyPrint(e) + ")";
case Sqrt(var e) -> "sqrt(" + prettyPrint(e) + ")";
case Add(var l, var r) -> "(" + prettyPrint(l) + " + " + prettyPrint(r) + ")";
case Subtract(var l, var r) -> "(" + prettyPrint(l) + " - " + prettyPrint(r) + ")";
case Multiply(var l, var r) -> "(" + prettyPrint(l) + " * " + prettyPrint(r) + ")";
case Divide(var l, var r) -> "(" + prettyPrint(l) + " / " + prettyPrint(r) + ")";
};
}
// Usage example
// Expression: sqrt(3^2 + 4^2) = 5
Expr expr = new Sqrt(
new Add(
new Multiply(new Literal(3), new Literal(3)),
new Multiply(new Literal(4), new Literal(4))
)
);
System.out.println(prettyPrint(expr)); // sqrt(((3.0 * 3.0) + (4.0 * 4.0)))
System.out.println(evaluate(expr)); // 5.0
The combination of sealed types and record patterns gives you what functional programming languages call algebraic data types (ADTs). You define a closed set of data variants, each carrying different data, and you process them with exhaustive pattern matching. Java now supports this pattern natively with full IDE support, debugging, and integration with the rest of the Java ecosystem.
| Benefit | How It Works |
|---|---|
| Exhaustiveness | Compiler verifies all sealed subtypes are handled |
| No boilerplate | Record patterns extract fields automatically |
| Compile-time safety | Adding a new sealed subtype breaks all incomplete switches |
| Recursive patterns | Nested records like Add(Expr, Expr) can be recursively processed |
| No Visitor needed | Pattern matching replaces the Visitor pattern for most use cases |
Let us see six real-world scenarios where record patterns dramatically simplify the code.
record Point(double x, double y) {}
// BEFORE: manual accessor calls
public double distanceBefore(Object a, Object b) {
if (a instanceof Point && b instanceof Point) {
Point p1 = (Point) a;
Point p2 = (Point) b;
double dx = p2.x() - p1.x();
double dy = p2.y() - p1.y();
return Math.sqrt(dx * dx + dy * dy);
}
throw new IllegalArgumentException("Expected two Points");
}
// AFTER: record patterns extract directly
public double distanceAfter(Object a, Object b) {
if (a instanceof Point(var x1, var y1) && b instanceof Point(var x2, var y2)) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
throw new IllegalArgumentException("Expected two Points");
}
record ApiResponse(int statusCode, String body, Mapheaders) {} // BEFORE: extract fields manually public void handleResponseBefore(Object response) { if (response instanceof ApiResponse) { ApiResponse r = (ApiResponse) response; int code = r.statusCode(); String body = r.body(); Map headers = r.headers(); if (code >= 200 && code < 300) { parseSuccess(body, headers.get("Content-Type")); } else if (code == 401) { refreshAuth(headers.get("WWW-Authenticate")); } else if (code == 429) { int wait = Integer.parseInt(headers.getOrDefault("Retry-After", "60")); retryAfter(wait); } else { logError(code, body); } } } // AFTER: record pattern extracts fields, switch dispatches public void handleResponseAfter(Object response) { switch (response) { case ApiResponse(var code, var body, var headers) when code >= 200 && code < 300 -> parseSuccess(body, headers.get("Content-Type")); case ApiResponse(var code, var body, var headers) when code == 401 -> refreshAuth(headers.get("WWW-Authenticate")); case ApiResponse(var code, var body, var headers) when code == 429 -> retryAfter(Integer.parseInt(headers.getOrDefault("Retry-After", "60"))); case ApiResponse(var code, var body, var headers) -> logError(code, body); default -> throw new IllegalArgumentException("Not an API response"); } }
record User(String name, String role, Listpermissions) {} record Resource(String type, String owner, boolean isPublic) {} record AccessRequest(User user, Resource resource, String action) {} // BEFORE: deeply nested accessor calls public boolean checkAccessBefore(Object request) { if (request instanceof AccessRequest) { AccessRequest ar = (AccessRequest) request; User user = ar.user(); Resource resource = ar.resource(); String action = ar.action(); if (user.role().equals("admin")) { return true; } if (resource.isPublic() && action.equals("read")) { return true; } if (resource.owner().equals(user.name())) { return true; } return user.permissions().contains(resource.type() + ":" + action); } return false; } // AFTER: nested record patterns make it declarative public boolean checkAccessAfter(Object request) { return switch (request) { case AccessRequest(User(var name, var role, var perms), var resource, var action) when role.equals("admin") -> true; case AccessRequest(var user, Resource(var type, var owner, var isPublic), var action) when isPublic && action.equals("read") -> true; case AccessRequest(User(var name, var role, var perms), Resource(var type, var owner, var pub), var action) when owner.equals(name) -> true; case AccessRequest(User(var name, var role, var perms), Resource(var type, var owner, var pub), var action) -> perms.contains(type + ":" + action); default -> false; }; }
record JsonObject(Mapfields) {} record JsonArray(List
record MouseEvent(int x, int y, String button) {}
record KeyEvent(int keyCode, boolean shift, boolean ctrl) {}
record TouchEvent(int x, int y, int fingers) {}
// BEFORE: instanceof + manual extraction
public String describeEventBefore(Object event) {
if (event instanceof MouseEvent) {
MouseEvent me = (MouseEvent) event;
return "Mouse " + me.button() + " at (" + me.x() + "," + me.y() + ")";
} else if (event instanceof KeyEvent) {
KeyEvent ke = (KeyEvent) event;
String modifiers = "";
if (ke.ctrl()) modifiers += "Ctrl+";
if (ke.shift()) modifiers += "Shift+";
return "Key " + modifiers + ke.keyCode();
} else if (event instanceof TouchEvent) {
TouchEvent te = (TouchEvent) event;
return te.fingers() + "-finger touch at (" + te.x() + "," + te.y() + ")";
}
return "Unknown event";
}
// AFTER: record patterns in switch
public String describeEventAfter(Object event) {
return switch (event) {
case MouseEvent(var x, var y, var button)
-> "Mouse " + button + " at (" + x + "," + y + ")";
case KeyEvent(var code, var shift, var ctrl) when ctrl && shift
-> "Key Ctrl+Shift+" + code;
case KeyEvent(var code, var shift, var ctrl) when ctrl
-> "Key Ctrl+" + code;
case KeyEvent(var code, var shift, var ctrl) when shift
-> "Key Shift+" + code;
case KeyEvent(var code, var shift, var ctrl)
-> "Key " + code;
case TouchEvent(var x, var y, var fingers) when fingers > 1
-> fingers + "-finger touch at (" + x + "," + y + ")";
case TouchEvent(var x, var y, var fingers)
-> "Touch at (" + x + "," + y + ")";
default -> "Unknown event";
};
}
record DatabaseConfig(String host, int port, String database) {}
record CacheConfig(String host, int port, int ttlSeconds) {}
record AppConfig(DatabaseConfig db, CacheConfig cache, boolean debugMode) {}
// BEFORE: nested extraction and validation
public List validateConfigBefore(Object config) {
List errors = new ArrayList<>();
if (config instanceof AppConfig) {
AppConfig app = (AppConfig) config;
DatabaseConfig db = app.db();
CacheConfig cache = app.cache();
if (db.host() == null || db.host().isEmpty()) {
errors.add("Database host is required");
}
if (db.port() <= 0 || db.port() > 65535) {
errors.add("Database port must be between 1 and 65535");
}
if (cache.ttlSeconds() < 0) {
errors.add("Cache TTL cannot be negative");
}
if (app.debugMode() && db.host().equals("production-db")) {
errors.add("Debug mode cannot be used with production database");
}
}
return errors;
}
// AFTER: nested record patterns for direct access
public List validateConfigAfter(Object config) {
List errors = new ArrayList<>();
if (config instanceof AppConfig(
DatabaseConfig(var dbHost, var dbPort, var database),
CacheConfig(var cacheHost, var cachePort, var ttl),
var debug)) {
if (dbHost == null || dbHost.isEmpty()) errors.add("Database host is required");
if (dbPort <= 0 || dbPort > 65535) errors.add("Database port must be between 1 and 65535");
if (ttl < 0) errors.add("Cache TTL cannot be negative");
if (debug && dbHost.equals("production-db")) errors.add("Debug mode cannot be used with production database");
}
return errors;
}
In every example, record patterns eliminate the manual extraction step. Instead of receiving an object and then pulling it apart with accessor calls, you declare the shape you expect and the fields are placed directly into variables. The code reads as a direct statement of intent: "if this is a Customer with a name and an Address containing a city and state, then..."
Let us build three complete, realistic examples that show how record patterns work in larger applications.
Here is a complete JSON data model using sealed interfaces and records, with operations built entirely on record patterns.
// Complete JSON data model
public sealed interface JsonValue permits
JsonNull, JsonBool, JsonNumber, JsonString, JsonArray, JsonObject {}
public record JsonNull() implements JsonValue {}
public record JsonBool(boolean value) implements JsonValue {}
public record JsonNumber(double value) implements JsonValue {}
public record JsonString(String value) implements JsonValue {}
public record JsonArray(List elements) implements JsonValue {}
public record JsonObject(Map fields) implements JsonValue {}
// Serialize to JSON string
public String toJson(JsonValue value) {
return switch (value) {
case JsonNull() -> "null";
case JsonBool(var b) -> String.valueOf(b);
case JsonNumber(var n) -> {
// Avoid trailing .0 for whole numbers
if (n == Math.floor(n) && !Double.isInfinite(n)) {
yield String.valueOf((long) n);
}
yield String.valueOf(n);
}
case JsonString(var s) -> "\"" + escapeJson(s) + "\"";
case JsonArray(var elements) -> elements.stream()
.map(this::toJson)
.collect(Collectors.joining(", ", "[", "]"));
case JsonObject(var fields) -> fields.entrySet().stream()
.map(e -> "\"" + escapeJson(e.getKey()) + "\": " + toJson(e.getValue()))
.collect(Collectors.joining(", ", "{", "}"));
};
}
// Type-safe field access
public Optional getField(JsonValue value, String fieldName) {
return switch (value) {
case JsonObject(var fields) -> Optional.ofNullable(fields.get(fieldName));
default -> Optional.empty();
};
}
// Deep equality check
public boolean jsonEquals(JsonValue a, JsonValue b) {
return switch (a) {
case JsonNull() -> b instanceof JsonNull;
case JsonBool(var v1) -> b instanceof JsonBool(var v2) && v1 == v2;
case JsonNumber(var v1) -> b instanceof JsonNumber(var v2) && v1 == v2;
case JsonString(var v1) -> b instanceof JsonString(var v2) && v1.equals(v2);
case JsonArray(var e1) -> {
if (b instanceof JsonArray(var e2) && e1.size() == e2.size()) {
for (int i = 0; i < e1.size(); i++) {
if (!jsonEquals(e1.get(i), e2.get(i))) yield false;
}
yield true;
}
yield false;
}
case JsonObject(var f1) -> {
if (b instanceof JsonObject(var f2) && f1.size() == f2.size()) {
for (var entry : f1.entrySet()) {
JsonValue v2 = f2.get(entry.getKey());
if (v2 == null || !jsonEquals(entry.getValue(), v2)) yield false;
}
yield true;
}
yield false;
}
};
}
// Count total nodes
public int countNodes(JsonValue value) {
return switch (value) {
case JsonNull() -> 1;
case JsonBool _ -> 1;
case JsonNumber _ -> 1;
case JsonString _ -> 1;
case JsonArray(var elements)
-> 1 + elements.stream().mapToInt(this::countNodes).sum();
case JsonObject(var fields)
-> 1 + fields.values().stream().mapToInt(this::countNodes).sum();
};
}
// Complete expression evaluator
public sealed interface Expr permits
Num, Var, BinOp, UnOp, Let, IfExpr {}
public record Num(double value) implements Expr {}
public record Var(String name) implements Expr {}
public record BinOp(String op, Expr left, Expr right) implements Expr {}
public record UnOp(String op, Expr operand) implements Expr {}
public record Let(String name, Expr value, Expr body) implements Expr {}
public record IfExpr(Expr condition, Expr then, Expr otherwise) implements Expr {}
// Evaluator using record patterns
public double eval(Expr expr, Map env) {
return switch (expr) {
case Num(var value) -> value;
case Var(var name) -> {
if (!env.containsKey(name)) {
throw new RuntimeException("Undefined variable: " + name);
}
yield env.get(name);
}
case BinOp(var op, var left, var right) -> {
double l = eval(left, env);
double r = eval(right, env);
yield switch (op) {
case "+" -> l + r;
case "-" -> l - r;
case "*" -> l * r;
case "/" -> {
if (r == 0) throw new ArithmeticException("Division by zero");
yield l / r;
}
case "%" -> l % r;
case "^" -> Math.pow(l, r);
default -> throw new RuntimeException("Unknown operator: " + op);
};
}
case UnOp(var op, var operand) -> switch (op) {
case "-" -> -eval(operand, env);
case "abs" -> Math.abs(eval(operand, env));
case "sqrt" -> Math.sqrt(eval(operand, env));
default -> throw new RuntimeException("Unknown operator: " + op);
};
case Let(var name, var value, var body) -> {
double v = eval(value, env);
var newEnv = new HashMap<>(env);
newEnv.put(name, v);
yield eval(body, newEnv);
}
case IfExpr(var cond, var then, var otherwise) -> {
double condValue = eval(cond, env);
yield condValue != 0 ? eval(then, env) : eval(otherwise, env);
}
};
}
// Pretty printer using record patterns
public String format(Expr expr) {
return switch (expr) {
case Num(var v) -> String.valueOf(v);
case Var(var name) -> name;
case BinOp(var op, var l, var r) -> "(" + format(l) + " " + op + " " + format(r) + ")";
case UnOp(var op, var e) -> op + "(" + format(e) + ")";
case Let(var name, var val, var body) -> "let " + name + " = " + format(val) + " in " + format(body);
case IfExpr(var cond, var then, var otherwise)
-> "if " + format(cond) + " then " + format(then) + " else " + format(otherwise);
};
}
// Usage: let x = 5 in let y = 3 in x * y + 2
Expr program = new Let("x", new Num(5),
new Let("y", new Num(3),
new BinOp("+",
new BinOp("*", new Var("x"), new Var("y")),
new Num(2)
)
)
);
System.out.println(format(program)); // let x = 5.0 in let y = 3.0 in ((x * y) + 2.0)
System.out.println(eval(program, Map.of())); // 17.0
// Complete event system with record patterns
public sealed interface AppEvent permits
UIEvent, NetworkEvent, SystemEvent {}
// UI Events
public sealed interface UIEvent extends AppEvent permits Click, Scroll, KeyPress, Resize {}
public record Click(int x, int y, String elementId) implements UIEvent {}
public record Scroll(int deltaX, int deltaY) implements UIEvent {}
public record KeyPress(char key, boolean ctrl, boolean alt) implements UIEvent {}
public record Resize(int width, int height) implements UIEvent {}
// Network Events
public sealed interface NetworkEvent extends AppEvent permits
RequestSent, ResponseReceived, ConnectionLost {}
public record RequestSent(String url, String method, long timestamp) implements NetworkEvent {}
public record ResponseReceived(String url, int statusCode, long latencyMs) implements NetworkEvent {}
public record ConnectionLost(String host, String reason) implements NetworkEvent {}
// System Events
public sealed interface SystemEvent extends AppEvent permits
MemoryWarning, DiskFull, CpuThrottle {}
public record MemoryWarning(long usedMB, long totalMB) implements SystemEvent {}
public record DiskFull(String drive, long availableMB) implements SystemEvent {}
public record CpuThrottle(double usagePercent) implements SystemEvent {}
// Event logger -- exhaustive, uses record patterns
public String logEvent(AppEvent event) {
return switch (event) {
// UI Events
case Click(var x, var y, var id)
-> String.format("[UI] Click on '%s' at (%d, %d)", id, x, y);
case Scroll(var dx, var dy)
-> String.format("[UI] Scroll delta=(%d, %d)", dx, dy);
case KeyPress(var key, var ctrl, var alt) when ctrl && alt
-> String.format("[UI] Key Ctrl+Alt+%c", key);
case KeyPress(var key, var ctrl, var alt) when ctrl
-> String.format("[UI] Key Ctrl+%c", key);
case KeyPress(var key, var ctrl, var alt)
-> String.format("[UI] Key '%c'", key);
case Resize(var w, var h)
-> String.format("[UI] Resize to %dx%d", w, h);
// Network Events
case RequestSent(var url, var method, var ts)
-> String.format("[NET] %s %s at %d", method, url, ts);
case ResponseReceived(var url, var code, var latency) when code >= 400
-> String.format("[NET] ERROR %d from %s (%dms)", code, url, latency);
case ResponseReceived(var url, var code, var latency)
-> String.format("[NET] %d from %s (%dms)", code, url, latency);
case ConnectionLost(var host, var reason)
-> String.format("[NET] Lost connection to %s: %s", host, reason);
// System Events
case MemoryWarning(var used, var total)
-> String.format("[SYS] Memory warning: %dMB / %dMB (%.0f%%)", used, total, 100.0 * used / total);
case DiskFull(var drive, var available)
-> String.format("[SYS] Disk %s almost full: %dMB remaining", drive, available);
case CpuThrottle(var usage) when usage > 95
-> String.format("[SYS] CRITICAL CPU: %.1f%%", usage);
case CpuThrottle(var usage)
-> String.format("[SYS] CPU throttle: %.1f%%", usage);
};
}
// Event filtering with record patterns
public boolean isHighPriority(AppEvent event) {
return switch (event) {
case ConnectionLost _ -> true;
case ResponseReceived(var url, var code, var latency) when code >= 500 -> true;
case MemoryWarning(var used, var total) when used > total * 0.9 -> true;
case DiskFull(var drive, var available) when available < 100 -> true;
case CpuThrottle(var usage) when usage > 95 -> true;
default -> false;
};
}
These three examples demonstrate the full power of record patterns in production-style code. The JSON data model shows recursive processing. The expression evaluator shows nested environments and operator dispatch. The event system shows a multi-level hierarchy with filtering and formatting. In all cases, record patterns eliminate boilerplate and make the code read as a direct description of the logic.
Record patterns are a powerful feature, but using them well requires some judgment. Here are guidelines for writing clean, maintainable code with record patterns.
If you only need the record itself (not its individual components), use a regular type pattern instead of a record pattern. Do not deconstruct a record just because you can.
record Employee(String name, String department, int salary) {}
// GOOD: use record pattern when you need individual fields
case Employee(var name, var dept, var salary) when salary > 100000
-> "High earner: " + name + " in " + dept;
// GOOD: use type pattern when you need the whole record
case Employee e -> processEmployee(e);
// AVOID: deconstructing just to reconstruct or pass the whole object
case Employee(var name, var dept, var salary)
-> processEmployee(new Employee(name, dept, salary)); // Pointless deconstruction
Record patterns can get long, especially with nested records. Use var to keep the pattern readable when the types are obvious from context.
// GOOD: var keeps it concise case Point(var x, var y) -> ... case Line(Point(var x1, var y1), Point(var x2, var y2)) -> ... // VERBOSE: explicit types add noise when types are obvious case Point(int x, int y) -> ... case Line(Point(int x1, int y1), Point(int x2, int y2)) -> ...
When patterns get wide, format them across multiple lines. Line breaks are free and make the structure visible.
// GOOD: multi-line for readability
case Customer(
var name,
Address(Street(var st, var num), City(var city, var state), var zip)
) -> formatAddress(name, num, st, city, state, zip);
// HARD TO READ: everything on one line
case Customer(var name, Address(Street(var st, var num), City(var city, var state), var zip)) -> formatAddress(name, num, st, city, state, zip);
Since record patterns create local variables, pick names that are clear in context. You do not need to match the record component names exactly.
record Line(Point start, Point end) {}
// GOOD: names describe the role in this context
case Line(Point(var startX, var startY), Point(var endX, var endY))
-> drawLine(startX, startY, endX, endY);
// ALSO GOOD: short names when the context is clear
case Line(Point(var x1, var y1), Point(var x2, var y2))
-> Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
// AVOID: meaningless single letters that hide intent
case Line(Point(var a, var b), Point(var c, var d))
-> doSomething(a, b, c, d);
Record patterns with sealed types give you exhaustive matching and automatic deconstruction. This is the gold standard for type-safe dispatching in Java 21+.
// GOLD STANDARD: sealed + records + exhaustive switch with record patterns public sealed interface Resultpermits Success, Failure {} public record Success (T value) implements Result {} public record Failure (String error, Exception cause) implements Result {} public void handle(Result result) { switch (result) { case Success(var value) -> process(value); case Failure(var error, var cause) -> { log.error(error, cause); notifyOps(error); } } // Exhaustive -- no default needed // If you add a new Result subtype, compiler flags this switch }
Just because you can nest three or four levels deep does not mean you should. If a pattern is hard to read, extract a helper method or break it into multiple steps.
// TOO DEEP: hard to read
case Order(var id, Customer(var name, Address(Street(var st, var num), City(var city, var state), var zip)), var items, var total)
-> // this is getting unwieldy
// BETTER: extract and simplify
case Order(var id, var customer, var items, var total) -> {
String address = formatAddress(customer); // Let the method handle the nesting
yield createShippingLabel(id, address, items);
}
| Practice | Guideline |
|---|---|
| When to deconstruct | Only when you need the components, not the whole record |
| Type inference | Use var when types are obvious; explicit types when they add clarity |
| Formatting | Break long patterns across multiple lines |
| Naming | Use meaningful names that describe the role, not just abbreviations |
| Sealed types | Combine with sealed types for exhaustive, compiler-verified matching |
| Nesting depth | Stop at 2-3 levels; extract methods for deeper hierarchies |
| Guard clauses | Use when with deconstructed fields for precise matching |
Record patterns are one of the most expressive features added to Java in years. They let you write code that describes the shape of your data directly, eliminating the ceremony of accessor calls and temporary variables. Combined with sealed types and switch expressions, they bring Java closer to the pattern matching capabilities of languages like Scala, Kotlin, and Rust -- while retaining the type safety, performance, and tooling that make Java the backbone of enterprise software.