Record Patterns, introduced in Java 16 (preview) and finalized in Java 21, represent a significant enhancement to pattern matching in Java. This article explores the evolution, syntax, and practical applications of record patterns for more expressive and concise data processing.
Evolution of Pattern Matching in Java
- Java 14: instanceof pattern matching
- Java 16: Records
- Java 17: Pattern matching for switch (preview)
- Java 19: Record patterns (preview)
- Java 21: Record patterns finalized
Basic Record Patterns
Step 1: Understanding the Foundation
// Basic record declaration
public record Point(int x, int y) {}
public record Circle(Point center, double radius) {}
public record Rectangle(Point topLeft, Point bottomRight) {}
public record Person(String name, int age, Address address) {}
public record Address(String street, String city, String zipCode) {}
Step 2: Traditional vs Record Pattern Approach
public class RecordPatternBasic {
// Traditional approach - verbose and repetitive
public static String traditionalInstanceof(Object obj) {
if (obj instanceof Point) {
Point point = (Point) obj;
return "Point: x=" + point.x() + ", y=" + point.y();
}
return "Unknown";
}
// Pattern matching approach - concise and safe
public static String patternInstanceof(Object obj) {
if (obj instanceof Point(int x, int y)) {
return "Point: x=" + x + ", y=" + y;
}
return "Unknown";
}
// Nested record patterns
public static double calculateArea(Object shape) {
if (shape instanceof Circle(Point center, double radius)) {
return Math.PI * radius * radius;
}
if (shape instanceof Rectangle(Point topLeft, Point bottomRight)) {
int width = Math.abs(bottomRight.x() - topLeft.x());
int height = Math.abs(bottomRight.y() - topLeft.y());
return width * height;
}
throw new IllegalArgumentException("Unknown shape: " + shape);
}
public static void main(String[] args) {
Point point = new Point(10, 20);
Circle circle = new Circle(new Point(5, 5), 10.0);
System.out.println(traditionalInstanceof(point)); // Verbose
System.out.println(patternInstanceof(point)); // Concise
System.out.println("Circle area: " + calculateArea(circle));
}
}
Record Patterns in Switch Expressions
Step 3: Advanced Pattern Matching with Switch
public class RecordPatternSwitch {
public record LoginEvent(String username, String ipAddress, String timestamp) {}
public record PaymentEvent(String transactionId, double amount, String currency) {}
public record SystemEvent(String component, String level, String message) {}
// Complex nested records
public record Customer(String id, String name, ContactInfo contact) {}
public record ContactInfo(Email email, Phone phone, Address address) {}
public record Email(String value, boolean verified) {}
public record Phone(String number, String type) {}
public static String processEvent(Object event) {
return switch (event) {
case LoginEvent(String username, String ip, String time) ->
String.format("Login attempt by %s from %s at %s", username, ip, time);
case PaymentEvent(String txId, double amount, String currency) ->
String.format("Payment %s: %.2f %s", txId, amount, currency);
case SystemEvent(String component, String level, String message) ->
String.format("[%s] %s: %s", level.toUpperCase(), component, message);
case null -> "Null event received";
default -> "Unknown event type: " + event.getClass().getSimpleName();
};
}
// Nested record patterns in switch
public static String extractContactInfo(Customer customer) {
return switch (customer) {
case Customer(String id, String name,
ContactInfo(Email email, Phone phone, Address address)) ->
String.format("Customer: %s (%s)%nEmail: %s%nPhone: %s",
name, id, email.value(), phone.number());
case Customer(String id, String name, null) ->
String.format("Customer: %s (%s) - No contact info", name, id);
};
}
// Pattern with guards
public static String analyzeEventWithGuard(Object event) {
return switch (event) {
case LoginEvent(String username, String ip, String time)
when ip.startsWith("192.168.") ->
"Internal login: " + username;
case LoginEvent(String username, String ip, String time)
when ip.startsWith("10.") ->
"Private network login: " + username;
case PaymentEvent(String txId, double amount, String currency)
when amount > 1000 ->
"Large payment detected: " + txId;
case PaymentEvent(String txId, double amount, String currency)
when amount < 0 ->
"Invalid negative payment: " + txId;
default -> processEvent(event);
};
}
public static void main(String[] args) {
LoginEvent login = new LoginEvent("john_doe", "192.168.1.100", "2024-01-15T10:30:00Z");
PaymentEvent payment = new PaymentEvent("TX123", 1500.0, "USD");
System.out.println(processEvent(login));
System.out.println(processEvent(payment));
System.out.println(analyzeEventWithGuard(login));
System.out.println(analyzeEventWithGuard(payment));
}
}
Advanced Nested Record Patterns
Step 4: Complex Data Structure Deconstruction
import java.util.*;
public class AdvancedRecordPatterns {
// Complex domain models
public record Order(
String orderId,
Customer customer,
List<OrderItem> items,
OrderStatus status,
PaymentInfo payment
) {}
public record OrderItem(
String productId,
String name,
int quantity,
double unitPrice
) {}
public record PaymentInfo(
String method,
String transactionId,
boolean completed,
double amount
) {}
public enum OrderStatus { PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED }
// Deeply nested pattern matching
public static String processOrder(Object order) {
return switch (order) {
case Order(String orderId,
Customer(String custId, String custName, _),
List<OrderItem> items,
OrderStatus status,
PaymentInfo payment) -> {
double total = calculateTotal(items);
yield String.format("Order %s: %s - Total: $%.2f - Status: %s",
orderId, custName, total, status);
}
case null -> "Null order";
default -> "Unknown order type";
};
}
// Nested patterns with collection processing
public static double calculateTotal(List<OrderItem> items) {
return items.stream()
.mapToDouble(item -> item.quantity() * item.unitPrice())
.sum();
}
// Pattern with collection decomposition
public static String analyzeOrderItems(Order order) {
return switch (order) {
case Order(String id, _, List<OrderItem> items, _, _)
when !items.isEmpty() -> {
// Process first item using pattern
if (items.get(0) instanceof OrderItem(String pid, String name, int qty, double price)) {
yield String.format("First item: %s (Qty: %d, Price: $%.2f)",
name, qty, price);
}
yield "No items to analyze";
}
case Order(_, _, List<OrderItem> items, _, _) when items.isEmpty() ->
"Empty order";
default -> "Invalid order";
};
}
// Var patterns for concise syntax
public static String processOrderWithVar(Object order) {
return switch (order) {
case Order(var orderId, var customer, var items, var status, var payment) ->
String.format("Order %s has %d items, status: %s",
orderId, items.size(), status);
case null -> "Null order";
default -> "Unknown";
};
}
public static void main(String[] args) {
Customer customer = new Customer("CUST001", "John Doe", null);
List<OrderItem> items = List.of(
new OrderItem("P001", "Laptop", 1, 999.99),
new OrderItem("P002", "Mouse", 2, 29.99)
);
PaymentInfo payment = new PaymentInfo("CREDIT_CARD", "TXN123", true, 1059.97);
Order order = new Order("ORD001", customer, items, OrderStatus.PROCESSING, payment);
System.out.println(processOrder(order));
System.out.println(analyzeOrderItems(order));
System.out.println(processOrderWithVar(order));
}
}
Generic Record Patterns
Step 5: Working with Generic Records
public class GenericRecordPatterns {
// Generic records
public record Pair<T, U>(T first, U second) {}
public record Result<T>(T data, boolean success, String message) {}
public record TreeNode<T>(T value, List<TreeNode<T>> children) {}
// Pattern matching with generic records
public static String processPair(Object pair) {
return switch (pair) {
case Pair<String, Integer>(String name, Integer age) ->
String.format("Name: %s, Age: %d", name, age);
case Pair<Integer, Integer>(Integer x, Integer y) ->
String.format("Coordinates: (%d, %d)", x, y);
case Pair<?, ?>(Object first, Object second) ->
String.format("Pair: %s and %s", first, second);
default -> "Not a pair";
};
}
// Processing result types
public static <T> String handleResult(Result<T> result) {
return switch (result) {
case Result<T>(T data, boolean success, String msg) when success ->
String.format("Success: %s - Data: %s", msg, data);
case Result<T>(T data, boolean success, String msg) when !success ->
String.format("Error: %s", msg);
};
}
// Recursive pattern matching for tree structures
public static <T> String traverseTree(TreeNode<T> node) {
return switch (node) {
case TreeNode<T>(T value, List<TreeNode<T>> children)
when children.isEmpty() ->
"Leaf: " + value;
case TreeNode<T>(T value, List<TreeNode<T>> children) ->
String.format("Node: %s with %d children", value, children.size());
};
}
// Advanced generic pattern with bounds
public record Container<T extends Number>(T value, String label) {}
public static String processContainer(Object container) {
return switch (container) {
case Container<Integer>(Integer value, String label) ->
String.format("Integer container '%s': %d", label, value);
case Container<Double>(Double value, String label) ->
String.format("Double container '%s': %.2f", label, value);
case Container<? extends Number>(Number value, String label) ->
String.format("Number container '%s': %s", label, value);
default -> "Unknown container";
};
}
public static void main(String[] args) {
Pair<String, Integer> person = new Pair<>("John", 30);
Pair<Integer, Integer> coordinates = new Pair<>(100, 200);
Result<String> successResult = new Result<>("Data loaded", true, "Success");
Result<String> errorResult = new Result<>(null, false, "Database error");
System.out.println(processPair(person));
System.out.println(processPair(coordinates));
System.out.println(handleResult(successResult));
System.out.println(handleResult(errorResult));
Container<Integer> intContainer = new Container<>(42, "answer");
Container<Double> doubleContainer = new Container<>(3.14159, "pi");
System.out.println(processContainer(intContainer));
System.out.println(processContainer(doubleContainer));
}
}
Real-World Use Cases
Step 6: Practical Applications
import java.time.*;
import java.util.*;
public class RealWorldRecordPatterns {
// Domain models for e-commerce
public record Product(String id, String name, Category category, Money price) {}
public record Category(String id, String name, Category parent) {}
public record Money(double amount, Currency currency) {}
public record Currency(String code, String symbol) {}
// API response patterns
public record ApiResponse<T>(T data, Meta meta, List<Error> errors) {}
public record Meta(int page, int size, int total, String timestamp) {}
public record Error(String code, String message, String details) {}
// Financial transaction patterns
public record Transaction(
String id,
TransactionType type,
Money amount,
Account fromAccount,
Account toAccount,
Instant timestamp
) {}
public enum TransactionType { DEPOSIT, WITHDRAWAL, TRANSFER, PAYMENT }
public record Account(String number, String holder, AccountType type) {}
public enum AccountType { CHECKING, SAVINGS, BUSINESS }
// Complex data processing with record patterns
public static String analyzeTransaction(Object transaction) {
return switch (transaction) {
case Transaction(String id,
TransactionType type,
Money amount,
Account from,
Account to,
Instant time) -> {
String analysis = switch (type) {
case DEPOSIT ->
String.format("Deposit to %s: %s", to.holder(), formatMoney(amount));
case WITHDRAWAL ->
String.format("Withdrawal from %s: %s", from.holder(), formatMoney(amount));
case TRANSFER ->
String.format("Transfer %s -> %s: %s",
from.holder(), to.holder(), formatMoney(amount));
case PAYMENT ->
String.format("Payment from %s: %s", from.holder(), formatMoney(amount));
};
yield String.format("[%s] %s at %s", id, analysis,
time.atZone(ZoneId.systemDefault()).toLocalTime());
}
case null -> "Null transaction";
default -> "Unknown transaction type";
};
}
// Processing API responses
public static <T> String handleApiResponse(ApiResponse<T> response) {
return switch (response) {
case ApiResponse<T>(T data, Meta meta, List<Error> errors)
when errors.isEmpty() ->
String.format("Success: page %d/%d", meta.page(),
(int) Math.ceil((double) meta.total() / meta.size()));
case ApiResponse<T>(T data, Meta meta, List<Error> errors)
when !errors.isEmpty() ->
String.format("Errors: %s",
errors.stream()
.map(Error::message)
.reduce((a, b) -> a + ", " + b)
.orElse("Unknown error"));
};
}
// Product catalog analysis
public static String analyzeProduct(Product product) {
return switch (product) {
case Product(String id, String name, Category category, Money price)
when price.amount() > 1000 ->
String.format("Premium product: %s ($%.2f)", name, price.amount());
case Product(String id, String name,
Category(String catId, String catName, Category parent),
Money price)
when parent != null ->
String.format("Subcategory product: %s > %s > %s",
parent.name(), catName, name);
case Product(String id, String name, Category category, Money price) ->
String.format("Standard product: %s ($%.2f)", name, price.amount());
};
}
private static String formatMoney(Money money) {
return String.format("%s%.2f", money.currency().symbol(), money.amount());
}
// Nested pattern with collection processing
public static String processProductBatch(List<Product> products) {
return switch (products) {
case List<Product> batch when batch.size() > 10 ->
String.format("Large batch: %d products", batch.size());
case List<Product> batch when !batch.isEmpty() -> {
// Analyze first product using pattern
if (batch.get(0) instanceof Product(String id, String name, _, Money price)) {
yield String.format("Batch starts with: %s ($%.2f)", name, price.amount());
}
yield "Batch analysis unavailable";
}
case List<Product> batch when batch.isEmpty() ->
"Empty product batch";
default -> "Invalid product list";
};
}
public static void main(String[] args) {
Currency usd = new Currency("USD", "$");
Money largeAmount = new Money(1500.0, usd);
Money smallAmount = new Money(99.99, usd);
Account john = new Account("123", "John Doe", AccountType.CHECKING);
Account jane = new Account("456", "Jane Smith", AccountType.SAVINGS);
Transaction transfer = new Transaction("TX001", TransactionType.TRANSFER,
largeAmount, john, jane, Instant.now());
Category electronics = new Category("CAT001", "Electronics", null);
Category computers = new Category("CAT002", "Computers", electronics);
Product laptop = new Product("P001", "Gaming Laptop", computers, largeAmount);
Product mouse = new Product("P002", "Wireless Mouse", electronics, smallAmount);
System.out.println(analyzeTransaction(transfer));
System.out.println(analyzeProduct(laptop));
System.out.println(analyzeProduct(mouse));
List<Product> productBatch = List.of(laptop, mouse);
System.out.println(processProductBatch(productBatch));
}
}
Best Practices and Performance
Step 7: Optimization and Guidelines
public class RecordPatternBestPractices {
public record DataPoint(int x, int y, String label, double value) {}
public record DataSet(String name, List<DataPoint> points, Statistics stats) {}
public record Statistics(double mean, double median, double stdDev) {}
// 1. Use exhaustive patterns
public static String processDataPoint(DataPoint point) {
return switch (point) {
case DataPoint(int x, int y, String label, double value)
when x > 0 && y > 0 ->
String.format("Positive quadrant: %s (%.2f)", label, value);
case DataPoint(int x, int y, String label, double value)
when x < 0 && y > 0 ->
String.format("Negative X quadrant: %s", label);
case DataPoint(int x, int y, String label, double value)
when x > 0 && y < 0 ->
String.format("Negative Y quadrant: %s", label);
case DataPoint(int x, int y, String label, double value) ->
String.format("Origin area: %s", label);
};
}
// 2. Prefer pattern variables over direct method calls
public static String analyzeDataSetTraditional(DataSet dataSet) {
// Less efficient - calls methods multiple times
if (dataSet.points().size() > 1000 &&
dataSet.stats().stdDev() > 10.0) {
return "Large volatile dataset: " + dataSet.name();
}
return "Normal dataset: " + dataSet.name();
}
public static String analyzeDataSetWithPatterns(Object dataSet) {
// More efficient - extracts once with patterns
return switch (dataSet) {
case DataSet(String name, List<DataPoint> points, Statistics stats)
when points.size() > 1000 && stats.stdDev() > 10.0 ->
"Large volatile dataset: " + name;
case DataSet(String name, List<DataPoint> points, Statistics stats) ->
"Normal dataset: " + name;
default -> "Unknown data structure";
};
}
// 3. Handle null cases explicitly
public static String safePatternMatching(Object obj) {
return switch (obj) {
case null -> "Null object";
case DataPoint(int x, int y, String label, double value) ->
String.format("Point: %s at (%d,%d)", label, x, y);
case DataSet(String name, List<DataPoint> points, Statistics stats)
when points != null && !points.isEmpty() ->
String.format("Dataset %s with %d points", name, points.size());
case DataSet(String name, null, Statistics stats) ->
"Dataset with null points: " + name;
default -> "Unknown: " + obj.getClass().getSimpleName();
};
}
// 4. Use var for complex nested patterns
public static String processComplexStructure(Object data) {
return switch (data) {
case DataSet(var name, var points, var stats) -> {
// Use extracted variables for complex logic
double coefficient = calculateCoefficient(points, stats);
return String.format("%s - Coefficient: %.3f", name, coefficient);
}
default -> "Unsupported data type";
};
}
private static double calculateCoefficient(List<DataPoint> points, Statistics stats) {
if (points == null || points.isEmpty()) return 0.0;
return points.size() / (stats.stdDev() + 1.0);
}
// 5. Combine with traditional Java features
public static Optional<String> findHighValuePoint(DataSet dataSet) {
return switch (dataSet) {
case DataSet(_, List<DataPoint> points, _) when points != null ->
points.stream()
.filter(point -> point instanceof DataPoint(_, _, _, double value)
&& value > 100.0)
.findFirst()
.map(point -> {
if (point instanceof DataPoint(_, _, String label, _)) {
return "High value point: " + label;
}
return "Unknown high value point";
});
default -> Optional.empty();
};
}
public static void main(String[] args) {
DataPoint point1 = new DataPoint(10, 20, "A", 150.0);
DataPoint point2 = new DataPoint(-5, 15, "B", 50.0);
DataPoint point3 = new DataPoint(0, 0, "C", 25.0);
List<DataPoint> points = List.of(point1, point2, point3);
Statistics stats = new Statistics(75.0, 50.0, 52.0);
DataSet dataSet = new DataSet("Sample Data", points, stats);
System.out.println(processDataPoint(point1));
System.out.println(processDataPoint(point2));
System.out.println(processDataPoint(point3));
System.out.println(analyzeDataSetWithPatterns(dataSet));
System.out.println(safePatternMatching(dataSet));
System.out.println(processComplexStructure(dataSet));
findHighValuePoint(dataSet).ifPresent(System.out::println);
}
}
Migration Strategy
Step 8: Transitioning from Traditional Code
public class RecordPatternMigration {
public record Employee(String id, String name, Department department, double salary) {}
public record Department(String code, String name, Manager manager) {}
public record Manager(String id, String name, int level) {}
// Traditional Java code
public static String processEmployeeTraditional(Object obj) {
if (obj instanceof Employee) {
Employee emp = (Employee) obj;
Department dept = emp.department();
if (dept != null) {
Manager mgr = dept.manager();
if (mgr != null && mgr.level() > 5) {
return "Senior executive: " + emp.name();
}
return "Employee: " + emp.name() + " in " + dept.name();
}
return "Employee without department: " + emp.name();
}
return "Not an employee";
}
// Modern record patterns approach
public static String processEmployeeModern(Object obj) {
return switch (obj) {
case Employee(String id, String name,
Department(String code, String deptName,
Manager(String mgrId, String mgrName, int level)),
double salary)
when level > 5 ->
"Senior executive: " + name;
case Employee(String id, String name,
Department(String code, String deptName, Manager manager),
double salary)
when deptName != null ->
"Employee: " + name + " in " + deptName;
case Employee(String id, String name, null, double salary) ->
"Employee without department: " + name;
default -> "Not an employee";
};
}
// Gradual migration - mixed approach
public static String processEmployeeHybrid(Object obj) {
if (obj instanceof Employee(String id, String name, Department dept, double salary)) {
// Use traditional logic for complex conditions
if (dept != null && dept.name().contains("Engineering")) {
return "Engineering employee: " + name;
}
return "General employee: " + name;
}
return "Not an employee";
}
public static void main(String[] args) {
Manager seniorManager = new Manager("M001", "Alice Johnson", 6);
Department engineering = new Department("ENG", "Engineering", seniorManager);
Employee employee = new Employee("E001", "Bob Smith", engineering, 75000.0);
System.out.println("Traditional: " + processEmployeeTraditional(employee));
System.out.println("Modern: " + processEmployeeModern(employee));
System.out.println("Hybrid: " + processEmployeeHybrid(employee));
}
}
Key Benefits and Conclusion
Benefits of Record Patterns:
- Conciseness: Reduce boilerplate code for data extraction
- Safety: Eliminate explicit casting and potential ClassCastException
- Readability: More declarative and intention-revealing code
- Maintainability: Easier to modify and extend pattern logic
- Performance: Potential optimizations by the JVM
When to Use Record Patterns:
- Processing complex nested data structures
- Implementing visitor-like patterns
- Data validation and transformation pipelines
- API response handling
- Domain-driven design implementations
Record patterns represent a significant step forward in making Java code more expressive, safe, and maintainable. By embracing this feature, developers can write more declarative code that clearly expresses their intent while reducing boilerplate and potential errors.