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.
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());
}
}Point[x=1, y=2]
1 2
true false
trueFor 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.
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());
}
}
}Money[amount=12.50, currency=USD]
negative amount: -1The 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.
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");
}
}
}[AAPL x 10, MSFT x 5]
[AAPL x 10]
safe order cannot be changedLeakyOrder 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.
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));
}
}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 |
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());
}
}3.14
6.00
10.00
truePolygon 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.
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);
}
}(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.
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)));
}
}approved 2000 cents
declined: insufficient funds
retry in 30sString.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;
equalsandhashCodeuse 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
equalsbecause arrays compare by reference. - Each permitted subclass of a sealed type must be
final,sealed, ornon-sealed. - Sealed types let
switchbe exhaustive withoutdefault, 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, andtoString. - Records cannot extend classes or declare extra instance fields; they can implement interfaces and have methods.
sealed ... permitscloses a hierarchy; permitted types live in the same module or package.- Records plus sealed interfaces form algebraic data types: products and sums.
finalfields fix references, not the objects they point to.
