JAVA / RECORDS, SEALED TYPES AND ENUMS
Customising records without breaking equality
Customise a record with validation, factories and extra methods while keeping generated equals and hashCode correct, and recognise what silently breaks them.
What you will learn
- Put validation and normalisation in the compact constructor, not in an accessor
- Predict a record's equality from its component fields, ignoring any methods you add
- Test the copy invariant: new R(r.a(), r.b()) must be equal to r
- Swap array components for lists and copy incoming collections with List.copyOf
Understanding Customising records without breaking equality
A record's equals, hashCode and toString are not written against its public API. The compiler emits them as a single invokedynamic call into a bootstrap method in java.lang.runtime, handing it one field getter per component, so those three methods read the private final fields directly. That is why everything you add to the record body is invisible to equality: static factories, derived instance methods, a hand-written toString, even a redeclared accessor. The mental model to carry is that a record's value is the tuple of its stored field values plus its record class, and your customisations sit outside that tuple.
The contract to judge every customisation against is the copy invariant: reading the components back out through the accessors and passing them to the canonical constructor must yield an equal record. Validation and normalisation belong in the compact canonical constructor because they rewrite the parameter before it becomes a field, so every construction path, whether a static factory, deserialisation or a copy built from accessor results, ends at the same canonical tuple. Normalising inside an accessor instead moves the transformation outside the tuple: the accessor reports a value the field does not hold, so two records that look identical through their accessors compare unequal, and record patterns, which do invoke the accessors, then disagree with equals.
The second way to break equality has nothing to do with what you declare and everything to do with the component types you picked, because the generated equals delegates to each component's own equals and hashCode. An array component compares by reference, so two records with identical contents are never equal, and a component holding a caller's mutable list means the record's hashCode changes underneath it after it has been filed in a HashSet. Records are only shallowly immutable, so copy a collection component into an immutable one in the constructor: that restores content-based equality and pins the hash code for the object's whole lifetime.
import java.util.Locale;
import java.util.Set;
public class Main {
record Money(String currency, long cents) {
Money {
if (cents < 0) {
throw new IllegalArgumentException("negative amount: " + cents);
}
currency = currency.toUpperCase(Locale.ROOT);
}
static Money euros(long cents) {
return new Money("eur", cents);
}
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(currency, cents + other.cents);
}
@Override
public String toString() {
return "%s %d.%02d".formatted(currency, cents / 100, cents % 100);
}
}
public static void main(String[] args) {
Money a = new Money("eur", 250);
Money b = new Money("EUR", 250);
System.out.println("a = " + a + ", b = " + b);
System.out.println("equal: " + a.equals(b));
System.out.println("same hashCode: " + (a.hashCode() == b.hashCode()));
System.out.println("found in set: " + Set.of(a).contains(Money.euros(250)));
System.out.println("sum: " + a.plus(b));
System.out.println("copy equals original: " + new Money(a.currency(), a.cents()).equals(a));
}
}A record's equality is defined by the tuple of its stored component fields, so a customisation is safe exactly when it leaves those field values canonical.
Worked examples
Normalising in the accessor versus the constructor
Shows that the generated equals and toString read the fields, so an accessor that normalises on read cannot make two records equal.
import java.util.Locale;
public class Main {
record LooseTag(String label) {
public String label() { // normalisation in the wrong place
return label.toLowerCase(Locale.ROOT);
}
}
record Tag(String label) {
Tag { // normalisation in the right place
label = label.toLowerCase(Locale.ROOT);
}
}
public static void main(String[] args) {
LooseTag l1 = new LooseTag("Java");
LooseTag l2 = new LooseTag("java");
System.out.println("accessors agree: " + l1.label().equals(l2.label()));
System.out.println("records equal: " + l1.equals(l2));
System.out.println("toString: " + l1);
System.out.println("copy equals original: " + new LooseTag(l1.label()).equals(l1));
Tag t1 = new Tag("Java");
Tag t2 = new Tag("java");
System.out.println("normalised records equal: " + t1.equals(t2));
System.out.println("normalised toString: " + t1);
}
}Example explained
Line 1label() lowercases on every read, so both LooseTag instances report the same label and the first line prints true.
Line 2The generated equals is bound to the label field, which still holds "Java" in l1 and "java" in l2, so the records are unequal.
Line 3The generated toString uses the same field getters, which is why it prints the unnormalised "Java" instead of the accessor's answer.
Line 4Tag rewrites the parameter in the compact constructor, so the field itself is canonical and equals, hashCode and toString all agree.
An array component compares by reference
Demonstrates that equality of a record is only as good as the equality of its component types.
import java.util.Arrays;
import java.util.List;
public class Main {
record Route(String name, String[] stops) { }
record SafeRoute(String name, List<String> stops) {
SafeRoute {
stops = List.copyOf(stops);
}
}
public static void main(String[] args) {
Route r1 = new Route("N1", new String[] { "Depot", "Pier" });
Route r2 = new Route("N1", new String[] { "Depot", "Pier" });
System.out.println("array records equal: " + r1.equals(r2));
System.out.println("array contents equal: " + Arrays.equals(r1.stops(), r2.stops()));
SafeRoute s1 = new SafeRoute("N1", List.of("Depot", "Pier"));
SafeRoute s2 = new SafeRoute("N1", Arrays.asList("Depot", "Pier"));
System.out.println("list records equal: " + s1.equals(s2));
System.out.println("list hashes equal: " + (s1.hashCode() == s2.hashCode()));
System.out.println(s1);
}
}Example explained
Line 1String[] inherits equals from Object, so the generated equals compares two distinct array references and answers false.
Line 2Arrays.equals proves the contents match, so the mismatch comes entirely from how arrays define equality.
Line 3List gives content-based equals and hashCode across different implementations, so an immutable copy and Arrays.asList compare equal.
Line 4List.copyOf in the compact constructor also detaches the record from the caller's list, and it makes the record's toString readable.
A mutable component moves the record inside a HashSet
Shows how a shared mutable collection changes a record's hashCode after it has been stored, so lookups miss.
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class Main {
record Playlist(String name, List<String> tracks) { }
record FrozenPlaylist(String name, List<String> tracks) {
FrozenPlaylist {
tracks = List.copyOf(tracks);
}
}
public static void main(String[] args) {
List<String> tracks = new ArrayList<>(List.of("one"));
Playlist p = new Playlist("mix", tracks);
Set<Playlist> saved = new HashSet<>();
saved.add(p);
System.out.println("found: " + saved.contains(p));
tracks.add("two");
System.out.println("found after mutation: " + saved.contains(p));
System.out.println("same object still in set: " + (saved.iterator().next() == p));
List<String> other = new ArrayList<>(List.of("one"));
FrozenPlaylist f = new FrozenPlaylist("mix", other);
Set<FrozenPlaylist> frozenSaved = new HashSet<>();
frozenSaved.add(f);
other.add("two");
System.out.println("frozen found after mutation: " + frozenSaved.contains(f));
}
}Example explained
Line 1saved.add(p) files p in the bucket chosen from its hashCode, which at that moment folds in a one-element list.
Line 2tracks.add("two") mutates the very list the record's field points at, so p.hashCode() changes even though p is the same object.
Line 3contains(p) now hashes to a different bucket and finds nothing, while iteration still yields the identical object, which is what makes this bug so quiet.
Line 4FrozenPlaylist copies the list before it becomes a field, so the caller's later mutation cannot touch its hash code.
Important notes
Primitive components are compared with the wrapper's compare semantics, matching Double.equals rather than ==, so two records holding Double.NaN are equal while 0.0 and -0.0 are not.
equals and hashCode are not final on a record, so nothing stops you replacing just one of them; if you must override, override both and re-check the accessor-to-constructor round trip.
Common mistakes
Trimming or lowercasing inside a redeclared accessor: the accessors agree, but equals still compares the raw fields, so two records that print the same value miss each other in a HashMap.
Storing the caller's mutable List, or keeping a String[] component: equality either becomes reference-based or drifts as the caller mutates, and set.contains stops finding a value that is still in the set.
Writing your own equals, for example to ignore a component, and letting the compiler generate hashCode: equal records then get different hashes, so a HashSet quietly keeps duplicates.
Adding a derived component such as record Circle(double radius, double area) just to cache it: the cached value joins the equality tuple, so rounding differences make two identical circles unequal.
Try it yourself
Change, predict, then run
Write record Range(int lo, int hi) whose compact constructor swaps the two values when lo > hi, then print new Range(5, 1).equals(new Range(1, 5)). Add both instances to a HashSet and print its size to confirm the two spellings collapse into one value.
Open the Java workspaceCheck your understanding
A record declares record Email(String address) and redeclares the accessor so address() returns address.toLowerCase(Locale.ROOT). What does new Email("A@x.com").equals(new Email("a@x.com")) return?
- false, because the generated equals compares the stored fields and never calls the accessor
- true, because the generated equals obtains each component by calling address()
- false, because a record's equals also requires the two operands to be the same object
- It does not compile, because a record accessor cannot be redeclared
Show answer
The compiler binds the generated equals to the component fields, so it sees "A@x.com" against "a@x.com" and answers false. Option 1 is tempting because the accessor looks like the record's public face, but neither equals, hashCode nor toString routes through it, which is exactly why normalisation has to happen in the compact constructor. Redeclaring an accessor is legal as long as it stays public and keeps the component's return type, and records are final so identity never enters the comparison.