PCSalt
YouTube GitHub
Back to Java
Java · 2 min read

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 subclassing
  • sealed — further restricted subclassing
  • non-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:

  1. Checks the type (Circle, Rectangle, Triangle)
  2. Extracts components (r, w, h, b)
  3. 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 whenUse classes when
Data is immutableYou need mutable state
Identity is based on contentIdentity is based on reference
No inheritance neededYou need a class hierarchy
DTOs, value objects, eventsEntities, 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.