Skip to content

08 - Records, Sealed Classes, Pattern Matching

What this session is

About an hour. You'll learn three modern Java features that, combined, let you model data and decisions in a way old Java couldn't. Records replace boilerplate-heavy data classes. Sealed classes restrict which classes can extend a type. Pattern matching in switch and instanceof makes type-based decisions clean.

If you've heard Java is "verbose" - this is the page that fixes that reputation.

Records: data classes without boilerplate

Recall the Account class from page 05:

public class Account {
    private final String owner;
    private double balance;

    public Account(String owner, double balance) {
        this.owner = owner;
        this.balance = balance;
    }

    public String getOwner() { return owner; }
    public double getBalance() { return balance; }

    @Override
    public String toString() {
        return "Account{owner=" + owner + ", balance=" + balance + "}";
    }

    @Override
    public boolean equals(Object o) { /* tedious */ }

    @Override
    public int hashCode() { /* tedious */ }
}

20+ lines for what's essentially "a pair of name and balance." Modern Java (16+):

public record Account(String owner, double balance) {}

One line. Java generates: - A constructor taking owner and balance. - Accessor methods owner() and balance() (note: no get prefix - the Java team chose shorter names). - equals, hashCode, toString - all the things you'd write by hand. - The class is implicitly final and the components are implicitly private final.

Use it:

var alice = new Account("Alice", 100.0);
System.out.println(alice.owner());     // Alice
System.out.println(alice.balance());   // 100.0
System.out.println(alice);             // Account[owner=Alice, balance=100.0]

Two records with the same component values are .equals - they're treated as the same data:

var a = new Account("Alice", 100);
var b = new Account("Alice", 100);
System.out.println(a.equals(b));    // true

Records with validation

The auto-generated constructor accepts anything. For validation, write a compact constructor:

public record Account(String owner, double balance) {
    public Account {
        if (balance < 0) {
            throw new IllegalArgumentException("balance cannot be negative");
        }
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("owner required");
        }
    }
}

No parameter list, no this.x = x. The validation runs before Java assigns the fields. If validation passes, Java does the assignment automatically.

Records with extra methods

Records can have methods like any other class:

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 cannot have non-static instance fields beyond their components. They can have static fields, methods, and nested types. They're for immutable bundles of data with optional logic, not for arbitrarily-shaped classes.

When to use a record vs a class

Use a record when Use a class when
The type is a bundle of fixed data You need mutable state
Two values with the same data should be equal Identity matters (one object ≠ another with same data)
You want immutability You need to inherit from another class
You're modeling a value (point, money, response DTO) You're modeling behavior (service, controller, manager)

Modern Java code uses records heavily for DTOs, value types, and many domain models.

Sealed classes/interfaces: closed hierarchies

Normally, anyone can extend your public class. Sealed types restrict who can:

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

public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

sealed interface Shape permits ... says: "only these three types are allowed to implement Shape." Anyone trying to add a fourth gets a compile error.

Why this matters: the compiler now knows the complete list of Shape subtypes. When you write a switch over a Shape, Java can verify you've covered every case. This combines with pattern matching to give you exhaustive case analysis - the kind functional languages have.

Pattern matching with switch

The "old way" of working with a polymorphic value (Shape) was a chain of instanceof + cast:

double area;
if (shape instanceof Circle) {
    Circle c = (Circle) shape;
    area = Math.PI * c.radius() * c.radius();
} else if (shape instanceof Square) {
    Square s = (Square) shape;
    area = s.side() * s.side();
} else if (shape instanceof Triangle) {
    Triangle t = (Triangle) shape;
    area = 0.5 * t.base() * t.height();
} else {
    throw new IllegalStateException("unknown shape");
}

Modern Java (21+):

double area = switch (shape) {
    case Circle c   -> Math.PI * c.radius() * c.radius();
    case Square s   -> s.side() * s.side();
    case Triangle t -> 0.5 * t.base() * t.height();
};

What's new: - case Circle c - matches when shape is a Circle; binds the matched value as c. - No default needed - because Shape is sealed, the compiler knows the three subtypes are the complete list. If you add a Pentagon implements Shape, every switch over Shape immediately gets a compile error pointing to where you forgot the case.

This is exhaustive pattern matching - the strongest correctness affordance modern Java offers. Use sealed + records + pattern-matching switch for any "value of several possible shapes" model.

Pattern matching with instanceof

For one-off type checks, pattern matching is built into instanceof too (Java 16+):

if (shape instanceof Circle c) {
    System.out.println("circle with radius " + c.radius());
}

The c is bound when the instanceof is true. Inside the if, you have c already cast - no manual cast needed.

Record patterns: destructuring (Java 21+)

You can match on the components of a record:

double area = switch (shape) {
    case Circle(double r)               -> Math.PI * r * r;
    case Square(double s)               -> s * s;
    case Triangle(double b, double h)   -> 0.5 * b * h;
};

Cleaner - no .radius(), just the named component directly. Especially nice for nested records:

record Point(int x, int y) {}
record Line(Point start, Point end) {}

Line line = new Line(new Point(0, 0), new Point(3, 4));

double length = switch (line) {
    case Line(Point(int x1, int y1), Point(int x2, int y2)) ->
        Math.hypot(x2 - x1, y2 - y1);
};

A complete worked example

Modeling a small "command" type with sealed + record + switch:

public sealed interface Command permits Add, Remove, List_, Quit {}

public record Add(String item) implements Command {}
public record Remove(String item) implements Command {}
public record List_() implements Command {}    // empty record
public record Quit() implements Command {}

public class Cart {
    private final java.util.List<String> items = new java.util.ArrayList<>();

    public void handle(Command c) {
        switch (c) {
            case Add(String item)    -> { items.add(item); System.out.println("added " + item); }
            case Remove(String item) -> { items.remove(item); System.out.println("removed " + item); }
            case List_()             -> System.out.println(items);
            case Quit()              -> System.out.println("bye");
        }
    }
}

That switch is exhaustive - add a fifth Command type and the compiler will tell you exactly where to update.

Exercise

In a new file Shapes.java:

  1. Define sealed interface Shape permits Circle, Square, Triangle.
  2. Define records Circle(double radius), Square(double side), Triangle(double base, double height) - each implements Shape.
  3. Write a static method area(Shape s) using a switch expression with record patterns (no default).
  4. In main, create a list of three shapes (one of each) and print each shape's area.

Expected output:

Circle[radius=2.0] -> 12.566370614359172
Square[side=3.0] -> 9.0
Triangle[base=4.0, height=5.0] -> 10.0

Stretch: add a fourth shape type (e.g., Pentagon). Remove the default case from your switch and try to compile. You should get a clear "non-exhaustive" error pointing at the switch. Add the Pentagon case; recompile.

What you might wonder

"Why no default in the switch?" Because the sealed hierarchy is closed. The compiler can prove you handled every possibility. Adding default defeats the exhaustiveness check - you want the compile error when you add a new subtype, not silent fallback.

"Can I extend a record?" No - records are implicitly final. The whole point is "this represents data; nothing should be in the way."

"What about Lombok?" Lombok is a third-party library that generates equals/hashCode/toString/constructors via annotations (@Data, @Builder). Records cover most of what Lombok's @Value and @Data did, without a third-party tool. New code: use records. Existing Lombok code: leave it.

"Are there other patterns?" Yes - null patterns, var patterns, and guard conditions (case Circle c when c.radius() > 100 -> ...). Look these up when you encounter them; they extend the pattern-matching story.

"Does this slow things down?" Records compile to regular classes; switch-with-patterns compiles to efficient bytecode (invokedynamic under the hood). No runtime overhead vs hand-written equivalents.

Done

You can now: - Define records for immutable data bundles (replacing 20-line classes). - Add validation via compact constructors. - Define sealed hierarchies for closed sets of types. - Use pattern matching in instanceof and switch. - Use record patterns to destructure values. - Recognize when the compiler is checking exhaustiveness for you.

This is one of the most productivity-changing modern Java features. Use it heavily.

Next page: generics - the type-parameter machinery you've been using implicitly with List<String>, explained properly.

Next: Generics →

Comments