Records, Sealed Classes & Pattern Matching (Java 17+)

The sixty-line Point

Open any older Java codebase and you will find classes like this: two fields, a constructor, two getters, and then equals, hashCode, and toString — written by hand or by an IDE — for a total of fifty or sixty lines whose only job is to carry two integers.

public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) { this.x = x; this.y = y; }
    public int getX() { return x; }
    public int getY() { return y; }

    // ... then equals(): 20 lines comparing x and y ...
    // ... then hashCode(): consistent with equals ...
    // ... then toString(): "Point{x=3, y=4}" ...
}

Every line is correct and every line is noise. None of it says anything about your problem; it says "this is a value with two parts," repeated in the syntax Java used to demand. Records delete that ceremony. Sealed classes solve the mirror problem — hierarchies that are supposed to be closed but aren't. And pattern matching turns the instanceof-and-cast dance into a single expression. Together, they are the default way to model data in modern Java.

These are not previews or experiments. Records have been stable since Java 16, sealed classes since Java 17, and pattern matching for switch plus record patterns since Java 21. In 2026, with Java 25 as the current LTS, this is simply how Java is written.

Records: one declaration, one whole class

A record is a compact declaration of a transparent data carrier — a class whose entire API is "here are my parts":

public record Point(int x, int y) { }

Point p = new Point(3, 4);
System.out.println(p);        // Point[x=3, y=4]
System.out.println(p.x());    // 3 — the accessor is named x(), not getX()

That one line gives you the complete class:

  • A final class — records cannot be extended, and they cannot extend anything else (they implicitly extend java.lang.Record).
  • Private final fields, one per component, in declaration order.
  • A canonical constructor taking every component — new Point(3, 4).
  • Accessor methods named exactly like the components: p.x(), p.y().
  • equals, hashCode, and toString generated from the components — value-based equality, for free.

Post 07 walked through the equals/hashCode contract line by painful line; a record honors it automatically, which is exactly why records make excellent HashMap keys:

Map<Point, String> labels = new HashMap<>();
labels.put(new Point(0, 0), "origin");
System.out.println(labels.get(new Point(0, 0)));   // origin — equal by value, found by hash

With a hand-written class you earned this behavior by writing thirty careful lines; with a record it is the default.

"Immutable" is shallow — and the leak is silent

Here is the sentence that causes the most production bugs with records: a record is immutable only as deeply as its components are. The fields are final, but final means "this reference cannot be reassigned," not "the object it points to cannot change."

record Team(String name, List<String> members) { }

Team team = new Team("backend", new ArrayList<>(List.of("anita", "bob")));
team.members().add("mallory");   // compiles! the List object is shared
System.out.println(team);        // Team[name=backend, members=[anita, bob, mallory]]

Nobody reassigned a field — the shared list mutated through the members() accessor. If that Team were a map key, its hashCode would now differ from insertion time: the post-07 mutable-key trap, in a record costume.

The fix is the compact constructor — a constructor with no parameter list that runs before the fields are assigned, where validation and defensive copying live:

record Team(String name, List<String> members) {
    public Team {
        Objects.requireNonNull(name, "name must not be null");
        members = List.copyOf(members);   // defensive, unmodifiable copy
    }
}

Team team = new Team("backend", new ArrayList<>(List.of("anita", "bob")));
team.members().add("mallory");   // UnsupportedOperationException — the copy is unmodifiable
System.out.println(team);        // Team[name=backend, members=[anita, bob]]

List.copyOf does two jobs: it copies (so later changes to the caller's list never leak in) and it returns an unmodifiable list (so nobody can mutate it through the accessor). Decision rule: defensively copy every mutable component in the compact constructor; components that are already immutable (String, int, LocalDate, other records) need nothing.

Records vs classes: the decision rule

Reach for a record when the thing you are modeling is its data:

  • DTOs and JSON payloads — API request/response shapes
  • Map keys, set elements, and any value object
  • Multiple return values bundled together (instead of an Object[])
  • Nodes in a closed hierarchy (you will see why in a moment)

Reach for a class when you need behavior, identity, or lifecycle:

  • Mutable state that changes over time (an order that transitions from NEW to SHIPPED)
  • Inheritance hierarchies, abstract classes, or frameworks that subclass you
  • Identity semantics — two instances are the same only if they are the same object
  • Framework-managed beans that are constructed empty and filled in later (more on this in the trap section)

The limits are the point: when a reviewer sees record, they know the object is created whole and equal by value; when they see class, they know to look for behavior.

Sealed classes: closing the hierarchy

Now the mirror problem. Some interfaces describe a closed domain — a fixed, known set of implementations you control: an expression language has constants, additions, and multiplications; a payment result is authorized, declined, or errored. With an ordinary interface, anyone can add a new implementation — a library user, a test double — and every if/switch chain over that interface silently becomes incomplete.

A sealed type closes the list. The compiler itself enforces who may implement it:

sealed interface Expr permits Const, Add, Mul { }

record Const(int value) implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
record Mul(Expr left, Expr right) implements Expr { }

The permitted list is part of the declaration, and the compiler rejects any implementation not on it. Permitted types must live in the same package (or module), and each one must declare how it closes the door: final (no further subclasses — what the records above do), sealed (restricts its own subclasses with another permits list), or non-sealed (opts that branch back out to open).

sealed interface Expr permits Const, Add, Mul — closed record Const final — int value record Add final — Expr left, right record Mul final — Expr left, right

Compare this with an ordinary interface: the diagram would have dotted lines fading into "…and anyone else, forever." Sealed replaces that uncertainty with a contract the compiler checks.

Decision rule: seal the domains you own and can enumerate — ASTs, result types (Success/Failure), state machines, message types in your own protocol. Keep interfaces open for extension points — plugin APIs, strategies your users implement, callbacks. Ask: "if a new implementation appears next year that I never heard of, is my code safer knowing about it (open), or safer being forced to handle it (sealed)?"

Pattern matching: instanceof without the ceremony

Before pattern matching, narrowing a type was a two-step ritual: test, then cast.

if (input instanceof String) {
    String s = (String) input;   // the cast we all wrote a thousand times
    System.out.println(s.toUpperCase());
}

Pattern matching merges the test and the binding. instanceof String s declares the variable and initializes it, in scope exactly where the test was true:

if (input instanceof String s && s.length() > 5) {
    System.out.println(s.toUpperCase());   // s is usable here, already a String
}
// s is NOT in scope here — the compiler tracks the flow

The && form works because the compiler understands short-circuiting — once the instanceof passes, s is definitely a String. Negation works too: if (!(input instanceof String s)) return; leaves s in scope after the line. No cast, no ceremony, and no way to test one type and cast to another.

Pattern matching in switch: the compiler proves you are done

This is where the three features fuse: a switch can take patterns as case labels, and over a sealed type the compiler knows the complete set of cases — it can prove the switch is exhaustive, so no default is needed:

static String describe(Expr e) {
    return switch (e) {
        case Const c -> "constant " + c.value();
        case Add a   -> "a sum";
        case Mul m   -> "a product";
    };   // no default — the compiler verified all permitted types are covered
}

Omitting default used to be a bug waiting to happen; here it is the feature. Add record Neg(Expr expr) to the permits list tomorrow and this switch stops compiling until the new case is handled — where an open interface with a default branch would have silently fallen through. Sealed hierarchies turn "forgot to update the switch" from a runtime surprise into a compile error.

Record patterns: deconstruct in the label

Case labels can destructure records directly — a record pattern pulls the components into variables:

static String describe(Expr e) {
    return switch (e) {
        case Const(int v)          -> "constant " + v;
        case Add(Expr l, Expr r)   -> "a sum";
        case Mul(Expr l, Expr r)   -> "a product";
    };
}

case Const(int v) matches any Const and binds its value component to v in one motion — the switch equivalent of the instanceof binding, nested inside the pattern.

when guards: conditions on cases

Sometimes matching the type is not enough — when adds a condition, and cases are tried in order (first match wins):

static String sign(Const c) {
    return switch (c) {
        case Const(int v) when v < 0  -> "negative";
        case Const(int v) when v == 0 -> "zero";
        case Const(int v)              -> "positive";
    };
}

Put guarded cases first and the unguarded catch-all last; if a when guard makes a later case unreachable, the compiler flags it — another small proof, for free.

The null sharp edge

One behavior catches everyone exactly once: switching on null throws NullPointerException — unless you say case null. A default branch does not catch null:

Expr e = null;
switch (e) {
    case Const(int v) -> System.out.println("const");
    case null         -> System.out.println("nothing");   // without this: NullPointerException
    case Add a        -> System.out.println("add");
    case Mul m        -> System.out.println("mul");
}

The idiom for "handle null and anything unexpected together" is case null, default -> .... Decision rule: if the value reaching your switch can be null — a map lookup result, an optional-ish return — write case null explicitly. Never let a pattern switch meet null by accident.

Worked example: an expression evaluator, end to end

All three features in one program — a tiny arithmetic language. The domain is closed by design (sealed), its nodes are pure data (records), and evaluation dispatches on shape (pattern-matching switch):

sealed interface Expr permits Const, Add, Mul { }

record Const(int value) implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
record Mul(Expr left, Expr right) implements Expr { }

public class Eval {
    static int eval(Expr e) {
        return switch (e) {
            case null                  -> throw new IllegalArgumentException("null expression");
            case Const(int v)          -> v;
            case Add(Expr l, Expr r)   -> eval(l) + eval(r);
            case Mul(Expr l, Expr r)   -> eval(l) * eval(r);
        };
    }

    public static void main(String[] args) {
        // (2 + 3) * 4
        Expr expr = new Mul(new Add(new Const(2), new Const(3)), new Const(4));
        System.out.println(eval(expr));   // 20
        System.out.println(expr);
    }
}

Output:

20
Mul[left=Add[left=Const[value=2], right=Const[value=3]], right=Const[value=4]]

Now imagine the follow-up: the language gains division. You add Div to the permits list — and every switch over Expr in the codebase fails to compile until it handles division. That is the sealed-pattern payoff: the language does your refactoring checklist for you.

Trap: treating a record like a bean

The most common misuse is reaching for a record where a classic JavaBean-shaped object is required. A record has no no-arg constructor and no setters — it is constructed complete, once:

record User(String name, String email) { }

User u = new User();        // does not compile — no no-arg constructor
u.name("new-name");         // does not compile — no setters, fields are final

Some frameworks build objects the bean way — no-arg constructor, then setters as data arrives (older ORM and XML/JSON binding setups). A record breaks those flows, not because records are flawed, but because the lifecycle doesn't fit. Decision rule: if a framework owns the object's lifecycle and fills it in after construction, use a class with the shape the framework expects. If you construct the object fully at one call site and it never changes after, use a record. Choose the tool that matches who is in charge of the object's birth.

Recap: the modern defaults

  • Records are the default for data carriers: one line, value equality, excellent map keys. Copy mutable components in the compact constructor — immutability is shallow.
  • Sealed types are the default for closed domains you control: the compiler enforces the permitted list, and every switch over the hierarchy is checked against it.
  • Pattern matching is the default for type tests: instanceof String s instead of test-then-cast, and switches whose case labels destructure records and carry when guards.
  • The combination — sealed interface + record nodes + pattern switch — is the default way to model any closed, tree-shaped domain: expressions, results, states, messages. Add a new variant and the compiler audits every switch for you.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number