Records, Sealed Types, and Pattern Matching

Learning goals

By the end of this chapter you should be able to:

  • Declare records for immutable data carriers and add compact validation in compact constructors
  • Use sealed classes/interfaces to restrict subtypes and model closed hierarchies
  • Apply pattern matching for instanceof and switch (JDK 21)
  • Destructure record patterns in switch and if

Records — concise data classes

A record declares immutable data with generated equals, hashCode, toString, and accessors:

public record Point(int x, int y) {
    public Point {
        if (x < 0 || y < 0) throw new IllegalArgumentException("negative");
    }

    public double distanceFromOrigin() {
        return Math.hypot(x, y);
    }
}
  • Components (x, y) are private final fields with accessor methods x(), y() — not JavaBean getX.
  • Canonical constructor can be compact (above) or explicit.
  • Records are final — cannot extend another class (may implement interfaces).
Point p = new Point(3, 4);
Point moved = new Point(p.x() + 1, p.y());

Use records for DTOs, value objects, AST nodes — not for JPA entities requiring mutable state or no-arg constructors.


Sealed types — closed hierarchies

Sealed restricts who may extend/implement:

public sealed interface Shape permits Circle, Rectangle, Triangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double w, double h) implements Shape {}
public final class Triangle implements Shape { /* ... */ }

Every permitted subtype must be final, sealed, or non-sealed. Enables exhaustive switch without default when combined with pattern matching.

Model domain sums: Result.Success | Result.Failure, payment methods, expression trees.


Pattern matching for instanceof

Before Java 16:

if (obj instanceof String) {
    String s = (String) obj;
    use(s);
}

Modern:

if (obj instanceof String s && !s.isBlank()) {
    use(s);
}

Scope of s is the block where pattern matches and additional conditions hold.


Switch expressions

String label = switch (day) {
    case MONDAY, FRIDAY -> "busy";
    case SATURDAY, SUNDAY -> "rest";
    default -> "normal";
};

yield in block form:

int hours = switch (day) {
    case MONDAY -> 8;
    case TUESDAY -> {
        log("training");
        yield 6;
    }
    default -> 8;
};

Switch is an expression — must produce a value or throw on all paths.


Pattern matching for switch (Java 21)

Switch on type with guards:

static double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.w() * r.h();
        case Triangle t -> t.area();  // custom class
    };
}

Compiler checks exhaustiveness for sealed Shape — missing case is a compile error.

Null handling: pass nullNullPointerException unless you add case null -> ....


Record patterns

Destructure nested records in one step:

static void print(Object o) {
    if (o instanceof Point(int x, int y)) {
        System.out.println(x + y);
    }
}

static String describe(Object o) {
    return switch (o) {
        case Point(int x, int y) when x == y -> "on diagonal";
        case Point(int x, int y) -> x + "," + y;
        default -> "unknown";
    };
}

Works with nested patterns: case Line(Point(int x1, int y1), Point(int x2, int y2)).


Combining sealed + records + switch

public sealed interface Expr permits Constant, Add, Neg {}

public record Constant(int value) implements Expr {}
public record Add(Expr left, Expr right) implements Expr {}
public record Neg(Expr inner) implements Expr {}

static int eval(Expr e) {
    return switch (e) {
        case Constant(int v) -> v;
        case Add(var l, var r) -> eval(l) + eval(r);
        case Neg(var inner) -> -eval(inner);
    };
}

This replaces visitor boilerplate for many compiler/DSL-style trees.


Common mistakes

  1. Mutable components in records — allowed but breaks value semantics if mutated after construction.
  2. Forgetting permits clause — sealed type and subclasses must agree in same module/package rules.
  3. Non-exhaustive switch — adding a new permitted subtype breaks compile until switch updated (feature, not bug).
  4. Record implements entity with setters — wrong tool; use a class.
  5. Using default on sealed switch — hides missing cases; omit when exhaustiveness is the goal.

Practice checkpoint

  1. Add validation: email record must contain @ — where? Answer: compact constructor: if (!email.contains("@")) throw ....

  2. Sealed Payment permits Card, Cash — can class Wire implements Payment compile? Answer: no — not in permits list (same module/package rules apply).

  3. switch (obj) { case String s -> ... } — obj is null? Answer: NPE unless case null handled.

  4. Record Point(int x,int y) — equals uses? Answer: generated equals over all components.


Interview angles

  • Record vs Lombok @Value: built-in, serializable caveats, no bytecode plugin.
  • Sealed + switch: algebraic data types in Java; compiler-checked exhaustiveness.
  • When not records: mutable JPA entities, inheritance-heavy frameworks, sensitive security tokens in toString.
  • Pattern matching evolution: instanceof → switch type patterns → record deconstruction.

Next: /java/learn/jvm/java-memory-structure/