Records
Records, finalized in Java 16 (JEP 395, after preview in Java 14–15), are a special kind of class declared with the record keyword that compactly defines an immutable data carrier: listing the record's components once in its header automatically generates a canonical constructor, private final fields, public accessor methods, and correct equals(), hashCode(), and toString() implementations — eliminating the substantial boilerplate that writing an equivalent immutable value class by hand had always required, and which IDEs had long auto-generated but never eliminated from the source file itself. This entry covers exactly what boilerplate records eliminate and why that boilerplate existed in the first place, the canonical constructor and compact constructor syntax for validation, what records deliberately cannot do (extend a class, declare instance fields, be non-final or abstract) and why those restrictions exist, and records implementing interfaces alongside static factory methods.
Motivation — The Boilerplate of Hand-Written Immutable Data Classes
// ── Before Java 16 — the full hand-written equivalent ──────────────────
public final class PointOld {
private final int x;
private final int y;
public PointOld(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PointOld that = (PointOld) o;
return x == that.x && y == that.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
@Override
public String toString() { return "PointOld[x=" + x + ", y=" + y + "]"; }
}
// ~25 lines of entirely mechanical code, all fully determined by "x and y"
// ── Java 16+ — the exact same behavior, one line ────────────────────────
record Point(int x, int y) {}
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.x()); // 1 — accessor named after the component, not getX()
System.out.println(p1.equals(p2)); // true — compiler-generated, considers both components
System.out.println(p1.hashCode() == p2.hashCode()); // true
System.out.println(p1); // Point[x=1, y=2] — compiler-generated toString()Canonical Constructor, Compact Constructor, and Validation
// ── Compact constructor — validation without re-listing parameters ─────
record Range(int start, int end) {
Range { // compact constructor — no parameter list (already implied by the header)
if (start > end) {
throw new IllegalArgumentException("start must be <= end: " + start + " > " + end);
}
// No "this.start = start;" needed — implicit assignment happens automatically
// AFTER this compact constructor body finishes running
}
}
Range valid = new Range(1, 10); // fine
// Range invalid = new Range(10, 1); // throws IllegalArgumentException immediately
// ── Compact constructor can transform/normalize parameters before storing
record Username(String value) {
Username {
value = value.strip().toLowerCase(); // reassigning the parameter itself —
// whatever "value" holds at the end of this block is what gets stored in the field
if (value.isBlank()) {
throw new IllegalArgumentException("Username cannot be blank");
}
}
}
Username u = new Username(" AlIcE ");
System.out.println(u.value()); // "alice" — trimmed and lowercased by the compact constructor
// ── Additional non-canonical constructors must delegate to the canonical one
record Point3(int x, int y, int z) {
Point3(int x, int y) {
this(x, y, 0); // MUST delegate to the canonical constructor — directly or via chain
}
// Point3(int x, int y) { this.x = x; this.y = y; this.z = 0; } // NOT ALLOWED —
// non-canonical constructors cannot assign fields directly, only delegate via this(...)
}Restrictions, Interface Implementation, and Static Factories
// ── Records cannot extend a class — implicitly extend java.lang.Record ──
// record Bad extends SomeClass { } // COMPILE ERROR — records cannot extend any class
// ── Records are implicitly final — cannot be extended ──────────────────
record Point(int x, int y) {}
// class ExtendedPoint extends Point { } // COMPILE ERROR — Point is implicitly final
// ── No additional INSTANCE fields beyond the declared components ──────
record Counter(int value) {
// private int extraInstanceField; // COMPILE ERROR — no extra instance state allowed
private static int totalCreated = 0; // STATIC fields ARE allowed — not per-instance state
}
// ── Records CAN implement interfaces ────────────────────────────────────
record Point(int x, int y) implements Comparable<Point> {
@Override
public int compareTo(Point other) {
return Integer.compare(this.x * this.x + this.y * this.y,
other.x * other.x + other.y * other.y);
}
}
// ── Static factory methods and additional instance methods ────────────
record Temperature(double celsius) {
static Temperature fromFahrenheit(double f) {
return new Temperature((f - 32) * 5.0 / 9.0); // alternate construction path
}
// Ordinary instance method — genuine behavior alongside the data:
double toFahrenheit() {
return celsius * 9.0 / 5.0 + 32;
}
}
Temperature t = Temperature.fromFahrenheit(98.6);
System.out.println(t.celsius()); // ~37.0
System.out.println(t.toFahrenheit()); // 98.6