Java Records In Depth
A deeper dive into Java records — canonical and compact constructors, validation, and use cases.
Java Records In Depth
A record is Java's way of declaring a class whose whole job is to carry data. Introduced as a preview in Java 14 and finalized in Java 16, a record collapses the usual boilerplate — private final fields, a constructor, accessors, equals, hashCode, and toString — into a single header line. The earlier chapter showed the syntax; this one goes deeper into how records actually behave: their canonical and compact constructors, how they enforce invariants, what immutability guarantees you get, and where they fit (and where they do not).
What the compiler generates for you
When you write record Point(int x, int y) {}, the compiler emits a final class with two private final fields, a public constructor that takes both, public accessor methods named exactly after the components (x(), y() — no get prefix), and value-based equals, hashCode, and toString.
record Point(int x, int y) {}
// Equivalent to (roughly) hand-writing:
// final class Point {
// private final int x;
// private final int y;
// Point(int x, int y) { this.x = x; this.y = y; }
// int x() { return x; }
// int y() { return y; }
// public boolean equals(Object o) { ... compares x and y ... }
// public int hashCode() { ... derived from x and y ... }
// public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
// }The x and y in the header are the record's components. The compiler-generated members are derived entirely from them, in declaration order.
Canonical and compact constructors
Every record has a canonical constructor whose parameters match the components. You rarely write it in full — instead you use the compact constructor, which omits the parameter list and the trailing this.field = field assignments. The compiler runs your code first, then assigns the (possibly modified) parameters to the fields. It is the natural home for validation and normalization.
record Range(int low, int high) {
Range { // compact constructor — no (int low, int high)
if (low > high) {
throw new IllegalArgumentException("low must be <= high");
}
low = Math.max(low, 0); // reassigning the parameter normalizes the field
}
}If you ever need the explicit canonical form (for example, to defensively copy a mutable component), write the full signature and do the assignments yourself:
record Tags(String name, List<String> values) {
Tags(String name, List<String> values) { // explicit canonical constructor
this.name = name;
this.values = List.copyOf(values); // defensive, unmodifiable copy
}
}Immutability and what records are not
Record fields are final, so the reference each component holds never changes after construction. That makes records shallowly immutable. But immutability stops at the reference: if a component points to a mutable object (like an ArrayList), callers who share that object can still mutate its contents. Defensive copies in the canonical constructor close that gap.
| Property | Records | Regular classes |
|---|---|---|
| Fields | always private final | your choice |
| Class | implicitly final | extendable unless final |
| Superclass | always java.lang.Record | any (default Object) |
| Accessors | auto-generated, no get prefix | hand-written |
equals/hashCode | value-based, generated | identity by default |
| Setters | none — immutable | allowed |
Because a record always extends java.lang.Record, it cannot extend another class. It can still implement interfaces, declare static members, and add instance methods.
Adding behavior, statics, and factories
A record is still a class. You can give it extra methods, static factory methods, static fields, and even nested types. The components define the state; everything else is ordinary Java.
record Money(String currency, long cents) {
static Money of(String currency, long cents) { // static factory
return new Money(currency, cents);
}
Money plus(Money other) { // derived behavior
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(currency, cents + other.cents); // returns a new value
}
}Records also pair naturally with sealed interfaces and pattern matching, modelling closed sets of data shapes — the backbone of algebraic-style data design in modern Java.
A worked example: records end to end
This program exercises a record's generated members, proves the immutability and class properties via reflection, enforces an invariant in a compact constructor, lists the record components in declaration order, and shows records working with collections and added behavior.
What to take from the run:
- The
Pointyou never wrote a body for still printedPoint[x=3, y=4], answereda.x(), and reportedequals by value: truewith matching hash codes — the compiler generated value-basedtoString, accessors,equals, andhashCodefrom the two components alone. - Reflection confirmed the contract the language guarantees:
is final class : true(records cannot be subclassed) andis a record : true(every record extendsjava.lang.Record), which is why there are no setters and the fields are immutable. - The
Range(9, 2)call was rejected withlow must be <= high. The compact constructor ran before the fields were assigned, so a record is never constructed in an invalid state — validation belongs there, not in a separate factory check. getRecordComponents()returned the components in declaration order aslow:int high:int, showing that a record's structure is reflectively introspectable — the basis for serialization libraries and frameworks that map records automatically.Money.of("USD", 500).plus(Money.of("USD", 250))producedUSD 750, anddistinct()collapsed two identicalPoint(0,0)values to leave2— records behave like proper values everywhere, including streams and sets, precisely because theirequals/hashCodecompare contents.
Practice
What does a record's compact constructor (for example 'Range { ... }') let you do that an ordinary explicit constructor body would otherwise require more code for?