W3docs

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.

PropertyRecordsRegular classes
Fieldsalways private finalyour choice
Classimplicitly finalextendable unless final
Superclassalways java.lang.Recordany (default Object)
Accessorsauto-generated, no get prefixhand-written
equals/hashCodevalue-based, generatedidentity by default
Settersnone — immutableallowed

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.

java— editable, runs on the server

What to take from the run:

  • The Point you never wrote a body for still printed Point[x=3, y=4], answered a.x(), and reported equals by value: true with matching hash codes — the compiler generated value-based toString, accessors, equals, and hashCode from the two components alone.
  • Reflection confirmed the contract the language guarantees: is final class : true (records cannot be subclassed) and is a record : true (every record extends java.lang.Record), which is why there are no setters and the fields are immutable.
  • The Range(9, 2) call was rejected with low 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 as low: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)) produced USD 750, and distinct() collapsed two identical Point(0,0) values to leave 2 — records behave like proper values everywhere, including streams and sets, precisely because their equals/hashCode compare contents.

Practice

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?