Guide contents

Lesson 7 of 71

Records and Sealed Types

Records (final in Java 16) model plain data with little code. Sealed types (final in Java 17) restrict which classes may extend a type. Together they model algebraic data types: a fixed set of shapes, each holding fixed data. After this lesson the reader can write validated value types and closed hierarchies that later lessons use with pattern matching.

Feature What it gives
record final class with private final fields, accessors, constructor, equals, hashCode, toString
Compact constructor validation and normalization without repeating the field assignments
sealed ... permits closed list of direct subtypes
final / sealed / non-sealed how each permitted subtype continues or ends the hierarchy

Example 1: the smallest record

A record is a class whose state is exactly the components listed in its header. The compiler generates the rest.

Java
public class SmallestRecord {
    record Point(int x, int y) {}

    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);
        System.out.println(a);
        System.out.println(a.x() + " " + a.y());
        System.out.println(a.equals(b) + " " + (a == b));
        System.out.println(a.hashCode() == b.hashCode());
    }
}
Output
Point[x=1, y=2]
1 2
true false
true

For Point(int x, int y) the compiler generates:

Member Behavior
private final int x, y one field per component
canonical constructor Point(int x, int y) assigns every field
x(), y() accessors named after components, no get prefix
equals same record class and all components equal
hashCode derived from all components
toString Name[c1=v1, c2=v2]

A record class is implicitly final and extends java.lang.Record, so it cannot extend another class. It may implement interfaces, declare static fields, static and instance methods, and nested types. It may not declare instance fields beyond its components.

Example 2: validation in a compact constructor

A compact constructor is a canonical constructor written without a parameter list. Its body runs first; the compiler adds the field assignments at the end, using the parameter values as they are after the body.

Java
import java.math.BigDecimal;

public class CompactConstructor {
    record Money(BigDecimal amount, String currency) {
        Money {
            if (amount.signum() < 0) {
                throw new IllegalArgumentException("negative amount: " + amount);
            }
            if (currency.length() != 3) {
                throw new IllegalArgumentException("bad currency: " + currency);
            }
            currency = currency.toUpperCase();          // normalize the parameter
            amount = amount.setScale(2);
        }
    }

    public static void main(String[] args) {
        System.out.println(new Money(new BigDecimal("12.5"), "usd"));
        try {
            new Money(new BigDecimal("-1"), "EUR");
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}
Output
Money[amount=12.50, currency=USD]
negative amount: -1

The body reassigns currency and amount, which are the constructor parameters, not the fields. Assigning this.currency inside a compact constructor is a compile error. A record can also have a full canonical constructor with an explicit parameter list, or extra constructors that delegate with this(...).

Example 3: shallow immutability and defensive copies

Record fields are final, but final only fixes the reference. If a component is a mutable object, its contents can still change. This is called shallow immutability.

Java
import java.util.ArrayList;
import java.util.List;

public class DefensiveCopy {
    record LeakyOrder(String id, List<String> lines) {}

    record Order(String id, List<String> lines) {
        Order {
            lines = List.copyOf(lines);   // unmodifiable copy, rejects nulls
        }
    }

    public static void main(String[] args) {
        var source = new ArrayList<>(List.of("AAPL x 10"));

        var leaky = new LeakyOrder("o-1", source);
        var safe = new Order("o-2", source);
        source.add("MSFT x 5");

        System.out.println(leaky.lines());
        System.out.println(safe.lines());
        try {
            safe.lines().add("GOOG x 1");
        } catch (UnsupportedOperationException e) {
            System.out.println("safe order cannot be changed");
        }
    }
}
Output
[AAPL x 10, MSFT x 5]
[AAPL x 10]
safe order cannot be changed

LeakyOrder stores the caller's list, so later changes by the caller show up inside the record. Order copies the list in the compact constructor. List.copyOf returns the same instance when the argument is already an unmodifiable list, so the copy is cheap in that case. Arrays as components have the same problem, and also break equals, because arrays compare by identity.

Example 4: methods, static factories, and interfaces on records

Records can hold behavior that depends only on their components. A common production shape is a value type with a static factory and derived values.

Java
import java.util.ArrayList;
import java.util.List;

public class RecordMethods {
    record Trade(String symbol, int quantity, long priceCents) implements Comparable<Trade> {
        static Trade parse(String line) {
            String[] parts = line.split(",");
            return new Trade(parts[0], Integer.parseInt(parts[1]), Long.parseLong(parts[2]));
        }

        long notionalCents() {
            return quantity * priceCents;
        }

        Trade withQuantity(int newQuantity) {
            return new Trade(symbol, newQuantity, priceCents);
        }

        @Override
        public int compareTo(Trade other) {
            return Long.compare(notionalCents(), other.notionalCents());
        }
    }

    public static void main(String[] args) {
        List<Trade> trades = new ArrayList<>();
        for (String line : List.of("AAPL,10,19000", "MSFT,3,41000", "IBM,20,9000")) {
            trades.add(Trade.parse(line));
        }
        trades.sort(null); // null means natural order: compareTo, smallest first
        for (int i = trades.size() - 1; i >= 0; i--) {
            Trade t = trades.get(i);
            System.out.println(t.symbol() + " " + t.notionalCents());
        }
        System.out.println(trades.getLast().withQuantity(1));
    }
}
Output
AAPL 190000
IBM 180000
MSFT 123000
Trade[symbol=AAPL, quantity=1, priceCents=19000]

A withX method returns a new record because the fields cannot change. Java has no built-in "with" syntax for records in Java 25; derived record creation has been proposed as a JEP but is not part of any release covered here.

Example 5: a sealed interface

A sealed class or interface lists the only classes allowed to extend or implement it directly, in a permits clause. Each permitted subtype must declare one of three modifiers:

Modifier Meaning
final no further subclasses (records are implicitly final)
sealed further subclasses, but again a closed list
non-sealed open again: any class may extend it
Java
public class SealedShapes {
    sealed interface Shape permits Circle, Rectangle, Polygon {}

    record Circle(double radius) implements Shape {}
    record Rectangle(double width, double height) implements Shape {}
    non-sealed interface Polygon extends Shape {}

    record Triangle(double base, double height) implements Polygon {}

    static double area(Shape shape) {
        if (shape instanceof Circle c) return Math.PI * c.radius() * c.radius();
        if (shape instanceof Rectangle r) return r.width() * r.height();
        if (shape instanceof Triangle t) return 0.5 * t.base() * t.height();
        throw new IllegalArgumentException("unknown polygon");
    }

    public static void main(String[] args) {
        System.out.printf("%.2f%n", area(new Circle(1)));
        System.out.printf("%.2f%n", area(new Rectangle(2, 3)));
        System.out.printf("%.2f%n", area(new Triangle(4, 5)));
        System.out.println(Shape.class.isSealed());
    }
}
Output
3.14
6.00
10.00
true

Polygon is non-sealed, so the hierarchy below it is open and the method needs a fallback. Permitted subtypes must be in the same module, or in the same package when the code is in the unnamed module. When all subtypes are in the same source file, the permits clause can be omitted and the compiler infers it.

Example 6: algebraic data types

An algebraic data type combines product types (a record holds an x and a y) with sum types (a value is a Circle or a Rectangle). Records give products; sealed interfaces give sums. A small expression language shows the pattern.

Java
public class ExpressionTree {
    sealed interface Expr permits Num, Add, Mul, Neg {}
    record Num(int 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 int eval(Expr e) {
        return switch (e) {
            case Num n -> n.value();
            case Add a -> eval(a.left()) + eval(a.right());
            case Mul m -> eval(m.left()) * eval(m.right());
            case Neg n -> -eval(n.operand());
        };
    }

    static String show(Expr e) {
        return switch (e) {
            case Num n -> Integer.toString(n.value());
            case Add a -> "(" + show(a.left()) + " + " + show(a.right()) + ")";
            case Mul m -> show(m.left()) + " * " + show(m.right());
            case Neg n -> "-" + show(n.operand());
        };
    }

    public static void main(String[] args) {
        Expr e = new Mul(new Add(new Num(2), new Num(3)), new Neg(new Num(4)));
        System.out.println(show(e) + " = " + eval(e));
        System.out.println(e);
    }
}
Output
(2 + 3) * -4 = -20
Mul[left=Add[left=Num[value=2], right=Num[value=3]], right=Neg[operand=Num[value=4]]]

The switch has no default. Because Expr is sealed and every permitted type is covered, the compiler knows the switch is exhaustive. Adding a Div record to permits makes both switches fail to compile until they handle it. Type patterns in switch were finalized in Java 21; Pattern Matching covers them in depth.

Example 7: a domain result type in production style

Services often return either a success or one of several known failures. A sealed result type makes every caller handle each case, without exceptions for expected outcomes.

Java
import java.util.Map;

public class PaymentResult {
    sealed interface Result permits Approved, Declined, Retry {}
    record Approved(String authCode, long amountCents) implements Result {}
    record Declined(String reason) implements Result {}
    record Retry(int afterSeconds) implements Result {}

    static final Map<String, Long> BALANCES = Map.of("acct-1", 50_00L, "acct-2", 5_00L);

    static Result charge(String account, long amountCents) {
        Long balance = BALANCES.get(account);
        if (balance == null) return new Retry(30);
        if (balance < amountCents) return new Declined("insufficient funds");
        return new Approved("AUTH-" + account.hashCode() % 1000, amountCents);
    }

    static String describe(Result r) {
        return switch (r) {
            case Approved a -> "approved " + a.amountCents() + " cents";
            case Declined d -> "declined: " + d.reason();
            case Retry t -> "retry in " + t.afterSeconds() + "s";
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(charge("acct-1", 20_00)));
        System.out.println(describe(charge("acct-2", 20_00)));
        System.out.println(describe(charge("acct-9", 20_00)));
    }
}
Output
approved 2000 cents
declined: insufficient funds
retry in 30s

String.hashCode is specified by the Java documentation, so the auth code is stable between runs, but the output above does not print it. Compared with a class hierarchy plus instanceof chains, the sealed version moves the "did we handle every case" check from code review to the compiler.

Records vs Lombok vs classes

Criterion record Lombok @Value / @Data Hand-written class
Boilerplate none none (annotation processor) all written by hand
Mutability fields always final @Value final, @Data mutable any
Inheritance cannot extend a class can extend can extend
Accessor names x() getX() any
Extra instance fields not allowed allowed allowed
Build dependency none compile-time library and IDE plugin none
JPA entities not suitable (needs no-arg constructor and mutable state) common common
Serialization uses the canonical constructor on deserialization, so validation runs normal field-based normal field-based

Records fit value objects, DTOs, map keys, event messages, and results. A class fits types with mutable state, identity, or inheritance needs. Lombok fits code bases that need JavaBean-style getters for frameworks.

Interview points

  • A record is a final, shallowly immutable carrier; equals and hashCode use all components.
  • A compact constructor validates and reassigns parameters; the compiler assigns the fields after the body.
  • Records holding mutable collections need a defensive copy (List.copyOf) to be truly immutable.
  • Array components break record equals because arrays compare by reference.
  • Each permitted subclass of a sealed type must be final, sealed, or non-sealed.
  • Sealed types let switch be exhaustive without default, so adding a subtype becomes a compile error at every unhandled switch.
  • Records deserialize through the canonical constructor, so validation also protects against crafted serialized data.

Summary

  • Records were finalized in Java 16, sealed classes in Java 17.
  • A record generates fields, canonical constructor, accessors, equals, hashCode, and toString.
  • Records cannot extend classes or declare extra instance fields; they can implement interfaces and have methods.
  • sealed ... permits closes a hierarchy; permitted types live in the same module or package.
  • Records plus sealed interfaces form algebraic data types: products and sums.
  • final fields fix references, not the objects they point to.