Java 21 — Record Patterns & Sealed Classes in Practice
Use Java 21's record patterns and sealed classes together — destructuring records in switch, nested patterns, and building type-safe domain models.
Records and sealed classes are two separate features. Together, they create something powerful: type-safe, exhaustive pattern matching with automatic destructuring. This is Java’s answer to Kotlin’s data class + sealed class + when.
Records — Immutable data carriers
A record is a concise way to declare an immutable data class:
public record User(String id, String name, String email) {}
The compiler generates:
- Constructor with all fields
- Getter methods (
id(),name(),email()) equals(),hashCode(),toString()
var user = new User("1", "Alice", "[email protected]");
System.out.println(user.name()); // Alice
System.out.println(user); // User[id=1, name=Alice, [email protected]]
Records with validation
public record Email(String value) {
public Email {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
}
}
public record Age(int value) {
public Age {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("Invalid age: " + value);
}
}
}
public record User(String id, String name, Email email, Age age) {}
The compact constructor (public Email { }) validates before assignment. After construction, the record is always valid.
Records with methods
public record Rectangle(double width, double height) {
public double area() {
return width * height;
}
public double perimeter() {
return 2 * (width + height);
}
public Rectangle scale(double factor) {
return new Rectangle(width * factor, height * factor);
}
}
Records can have methods. They just can’t have mutable fields (no setters, no non-final fields).
Sealed classes — Restricted hierarchies
A sealed class restricts which classes can extend it:
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 base, double height) implements Shape {}
No one else can implement Shape. The compiler knows every possible subtype.
Sealed with abstract classes
public sealed abstract class Account
permits SavingsAccount, CheckingAccount, InvestmentAccount {
private final String id;
private final String owner;
protected Account(String id, String owner) {
this.id = id;
this.owner = owner;
}
public String id() { return id; }
public String owner() { return owner; }
public abstract double balance();
}
public final class SavingsAccount extends Account {
private final double balance;
private final double interestRate;
public SavingsAccount(String id, String owner, double balance, double interestRate) {
super(id, owner);
this.balance = balance;
this.interestRate = interestRate;
}
@Override
public double balance() { return balance; }
public double interestRate() { return interestRate; }
}
// ... CheckingAccount, InvestmentAccount similarly
Subtypes must be final, sealed, or non-sealed:
final— no further subclassingsealed— further restricted subclassingnon-sealed— open for extension (escape hatch)
Record patterns — Destructuring
Record patterns let you extract record components directly in instanceof and switch:
record Point(int x, int y) {}
// Before — extract manually
if (obj instanceof Point p) {
int x = p.x();
int y = p.y();
System.out.println("Point at " + x + ", " + y);
}
// After — destructure in the pattern
if (obj instanceof Point(int x, int y)) {
System.out.println("Point at " + x + ", " + y);
}
In switch
static String describe(Shape shape) {
return switch (shape) {
case Circle(var r) ->
"Circle with radius %.2f (area: %.2f)".formatted(r, Math.PI * r * r);
case Rectangle(var w, var h) ->
"Rectangle %s (area: %.2f)".formatted(
w == h ? "(square)" : w + "x" + h, w * h);
case Triangle(var b, var h) ->
"Triangle with base %.2f and height %.2f".formatted(b, h);
};
}
The compiler:
- Checks the type (
Circle,Rectangle,Triangle) - Extracts components (
r,w,h,b) - Verifies exhaustiveness (all Shape subtypes handled)
Nested record patterns
record Address(String city, String country) {}
record Customer(String name, Address address) {}
static String getCity(Object obj) {
return switch (obj) {
case Customer(var name, Address(var city, var country))
when country.equals("US") -> city + ", USA";
case Customer(var name, Address(var city, _)) -> city;
default -> "unknown";
};
}
You can destructure nested records in a single pattern. The _ (Java 25) matches anything without binding.
Practical examples
API response handling
sealed interface ApiResponse<T>
permits ApiResponse.Success, ApiResponse.ClientError, ApiResponse.ServerError {
record Success<T>(T data, int statusCode) implements ApiResponse<T> {}
record ClientError<T>(String message, int statusCode) implements ApiResponse<T> {}
record ServerError<T>(String message, int statusCode) implements ApiResponse<T> {}
}
static <T> T unwrap(ApiResponse<T> response) {
return switch (response) {
case ApiResponse.Success(var data, _) -> data;
case ApiResponse.ClientError(var msg, var code) when code == 404 ->
throw new NotFoundException(msg);
case ApiResponse.ClientError(var msg, var code) when code == 401 ->
throw new UnauthorizedException(msg);
case ApiResponse.ClientError(var msg, _) ->
throw new ClientException(msg);
case ApiResponse.ServerError(var msg, _) ->
throw new ServerException(msg);
};
}
State machines
sealed interface OrderState
permits OrderState.Draft, OrderState.Placed, OrderState.Shipped,
OrderState.Delivered, OrderState.Cancelled {
record Draft(List<Item> items) implements OrderState {}
record Placed(List<Item> items, Instant placedAt) implements OrderState {}
record Shipped(String trackingNumber, Instant shippedAt) implements OrderState {}
record Delivered(Instant deliveredAt) implements OrderState {}
record Cancelled(String reason, Instant cancelledAt) implements OrderState {}
}
static String statusMessage(OrderState state) {
return switch (state) {
case OrderState.Draft(var items) ->
"%d items in cart".formatted(items.size());
case OrderState.Placed(_, var at) ->
"Order placed on %s".formatted(at);
case OrderState.Shipped(var tracking, _) ->
"Shipped — tracking: %s".formatted(tracking);
case OrderState.Delivered(var at) ->
"Delivered on %s".formatted(at);
case OrderState.Cancelled(var reason, _) ->
"Cancelled: %s".formatted(reason);
};
}
Expression evaluation
sealed interface Expr
permits Expr.Num, Expr.Add, Expr.Mul, Expr.Neg {
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {}
}
static double eval(Expr expr) {
return switch (expr) {
case Expr.Num(var v) -> v;
case Expr.Add(var l, var r) -> eval(l) + eval(r);
case Expr.Mul(var l, var r) -> eval(l) * eval(r);
case Expr.Neg(var e) -> -eval(e);
};
}
static String print(Expr expr) {
return switch (expr) {
case Expr.Num(var v) -> String.valueOf(v);
case Expr.Add(var l, var r) -> "(%s + %s)".formatted(print(l), print(r));
case Expr.Mul(var l, var r) -> "(%s * %s)".formatted(print(l), print(r));
case Expr.Neg(var e) -> "(-%s)".formatted(print(e));
};
}
Records vs classes — When to use each
| Use records when | Use classes when |
|---|---|
| Data is immutable | You need mutable state |
| Identity is based on content | Identity is based on reference |
| No inheritance needed | You need a class hierarchy |
| DTOs, value objects, events | Entities, services, builders |
Summary
Records + sealed classes + pattern matching = Java’s modern type system:
- Records — immutable data with auto-generated boilerplate
- Sealed classes — restricted type hierarchies
- Record patterns — destructure records in switch/instanceof
- Exhaustive switch — compiler checks all subtypes are handled
- Guards — add conditions to patterns with
when
This combination replaces verbose instanceof chains, visitor patterns, and manual type checking with concise, type-safe, compiler-verified code.