skip to content

records

data carriers in one line. what the compiler generates for you.

chapter
chapter 08.03
reading time
6 min read
java 16+

a record is a class that is just its data: record Point(int x, int y) {}. from that one line the compiler writes private final fields, a canonical constructor, accessors x() and y(), and equals, hashCode and toString based on the components. final in java 16.

records are shallowly immutable: the fields can't change, but an object a field points to (a list, say) still can.

the picture

you know the class with two fields, a constructor, two getters, equals, hashCode and toString, fifty lines that all say "this is a pair of ints"? and how adding a third field means editing six places, and forgetting one of them is a bug? a record says the data once and derives the rest.

think of a form with labelled boxes. a record is the form: the boxes are the components, and once it's filled in and stamped, nobody can rub a box out. two forms with the same answers are the same form, as far as anyone cares.

record Point(int x, int y) {}expands to ↓
what javac writes for you
  1. 1public final class Point extends java.lang.Record {
  2. 2 private final int x;
  3. 3 private final int y;
  4. 4
  5. 5 public Point(int x, int y) {
  6. 6 this.x = x;
  7. 7 this.y = y;
  8. 8 }
  9. 9
  10. 10 public int x() { return x; }
  11. 11 public int y() { return y; }
  12. 12
  13. 13 public final boolean equals(Object o) { /* compare x, y */ }
  14. 14 public final int hashCode() { /* mix x, y */ }
  15. 15 public final String toString() { /* "Point[x=1, y=2]" */ }
  16. 16}
$ javap -p Point.class
public final class Point extends java.lang.Record {
private final int x;
private final int y;
public Point(int, int);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int x();
public int y();
}

one private final field per component, and no others: a record can't declare extra instance fields. that's what makes it 'just its data'.

pick a member. the left side is what record Point(int x, int y) turns into, written out as java. the right side is javap -p on the real class file from javac 25. every member on the right came from that one line.

using one

Main.java
record Point(int x, int y) {}
 
void main() {
    var p = new Point(1, 2);
    IO.println(p);                                   // toString
    IO.println(p.x() + p.y());                       // accessors
    IO.println(p.equals(new Point(1, 2)) + " " + (p == new Point(1, 2)));
    IO.println(p.hashCode() == new Point(1, 2).hashCode());
}
output
Point[x=1, y=2]
3
true false
true

equals compares values, == still compares identity: two different objects, equal contents. records can also have static fields, static methods, instance methods, nested types, and they can implement interfaces. what they can't have is extra state:

text
record R(int a) { int b; }
error: field declaration must be static
  (consider replacing field with record component)

compact constructors: validation without the boilerplate

the canonical constructor is generated, but you can write it yourself to check arguments. the compact form drops the parameter list and the field assignments: you write the checks, and the assignments happen after your code runs.

Main.java
record Range(int lo, int hi) {
    Range {                                   // no (int lo, int hi), no this.lo = lo
        if (lo > hi) throw new IllegalArgumentException(lo + " > " + hi);
    }
}
 
void main() {
    IO.println(new Range(1, 5));
    IO.println(new Range(5, 1));
}
output
Range[lo=1, hi=5]
Exception in thread "main" java.lang.IllegalArgumentException: 5 > 1

inside a compact constructor, lo and hi are the parameters, and you can reassign them to normalize (hi = Math.max(hi, 0)). what you can't do is touch the fields: this.lo = lo there is error: cannot assign a value to final variable lo, because the compiler does that assignment itself, at the end.

shallowly immutable

the fields are final, so a record's components can never point somewhere else. but final is about the arrow, not the thing it points at:

stack
main
list
t
heap
Team
name
members
String
"core"
ArrayList<String>
0
1
String
"ada"
String
"bob"
the members field is final: that arrow will always point at the same list. the list itself is an ordinary ArrayList, so anyone holding it can still add to it.
don'tthe caller's list leaks in
java
record Team(String name, List<String> members) {}
 
var list = new ArrayList<>(List.of("ada"));
var t = new Team("core", list);
list.add("bob");          // t.members() now has bob
docopy in the compact constructor
java
record Team(String name, List<String> members) {
    Team {
        Objects.requireNonNull(name);
        members = List.copyOf(members);
    }
}

List.copyOf makes an unmodifiable copy, so the record stops sharing the caller's list and t.members().add(...) throws UnsupportedOperationException. that's the record version of a defensive copy.

predict the output
java
record Tagged(int[] data) {}
 
IO.println(new Tagged(new int[]{1}).equals(new Tagged(new int[]{1})));
keylines
  • record Point(int x, int y) {}: fields, constructor, accessors, equals, hashCode, toString. one line.
  • a record is final, extends Record, and has no state besides its components.
  • compact constructors validate and normalize. the field assignments happen after your code.
  • equality is by value. arrays inside break that, because arrays compare by identity.
  • shallowly immutable: copy mutable components with List.copyOf and friends.