JAVA / RECORDS, SEALED TYPES AND ENUMS
Data carriers and the boilerplate problem
Recognise value-like classes and hand-write the equals, hashCode and toString they need, and explain why the inherited versions break collections.
What you will learn
- Explain why two new Point(3, 4) objects are unequal until equals is overridden
- Write equals, hashCode and toString from one field list using Objects.hash
- Spot an equals(Point) overload that never overrides Object.equals
- Judge whether a class is a value carrier or an identity object
Understanding Data carriers and the boilerplate problem
A data carrier is a class whose entire job is to hold a few values together: a coordinate, a currency amount, a parsed date range. Every class inherits three behaviours from Object that assume the opposite of that job: equals compares references, hashCode returns a number tied to that one instance, and toString prints the class name plus that number in hex. Those defaults are correct for something with a lifecycle, like an open socket or a running task, where two objects holding the same data really are two different things. They are wrong for Point(3, 4), which ought to be interchangeable with any other Point(3, 4).
The work needed to fix that is not just typing volume, it is a contract. Hash-based collections pick a bucket from hashCode and only compare with equals inside that bucket, so if equals says two Points match while hashCode disagrees, an object you just put into a HashMap becomes unfindable. The field list also ends up repeated in four places, the constructor, equals, hashCode and toString, with nothing verifying that they still agree, so the day someone adds a z field the class still compiles and quietly compares only x and y.
The useful mental model is to sort a class into one of two buckets before writing any method. An identity object has state that changes and a meaning beyond its contents, so inherited reference equality is the right answer. A value carrier is nothing but its components, and for it equals, hashCode and toString are mechanical: they follow from the component list with no decisions left to make. That mechanical quality is precisely why a compiler can generate them, which is what a record is for; the next lessons cover its syntax.
import java.util.HashSet;
import java.util.Set;
public class DataCarrier {
// A hand-written data carrier: fields, a constructor, accessors, nothing else.
static 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 static void main(String[] args) {
Point a = new Point(3, 4);
Point b = new Point(3, 4);
System.out.println("fields are equal: " + (a.x() == b.x() && a.y() == b.y()));
System.out.println("a.equals(b): " + a.equals(b));
System.out.println("toString is Object's: " + a.toString().startsWith("DataCarrier$Point@"));
Set<Point> visited = new HashSet<>();
visited.add(a);
System.out.println("visited.contains(b): " + visited.contains(b));
visited.add(b);
System.out.println("visited.size(): " + visited.size());
}
}A class that is nothing but its field values needs equals, hashCode and toString derived from exactly those fields, and hand-writing them is boilerplate that drifts out of sync with the fields.
Worked examples
The full hand-written version
Shows the whole boilerplate a two-field carrier needs before a HashSet treats equal contents as one element.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class HandWritten {
static final class Point {
private final int x;
private final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point other = (Point) o;
return x == other.x && y == other.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point[x=" + x + ", y=" + y + "]";
}
}
public static void main(String[] args) {
Point a = new Point(3, 4);
Point b = new Point(3, 4);
Set<Point> visited = new HashSet<>();
visited.add(a);
visited.add(b);
System.out.println(a);
System.out.println("a.equals(b): " + a.equals(b));
System.out.println("hash codes agree: " + (a.hashCode() == b.hashCode()));
System.out.println("visited.size(): " + visited.size());
}
}Example explained
Line 1The instanceof check also rejects null, since instanceof is false for null, so no separate null test is needed.
Line 2Objects.hash(x, y) uses exactly the fields equals compares; a field in one and not the other violates the contract.
Line 3visited.size() is 1 because the matching hash sent the lookup to the right bucket and equals then confirmed the match.
Line 4Roughly twenty lines of mechanical code carry two ints, and adding a z field means editing three separate methods.
equals that never overrides equals
Demonstrates an equals with the wrong parameter type, which compiles, looks right in direct calls and still leaves collections on reference comparison.
import java.util.List;
public class OverloadTrap {
static final class Money {
private final long cents;
Money(long cents) {
this.cents = cents;
}
// Overload, not an override: the parameter is Money, not Object.
public boolean equals(Money other) {
return cents == other.cents;
}
}
public static void main(String[] args) {
Money a = new Money(250);
Money b = new Money(250);
Object bAsObject = b;
System.out.println("a.equals(b): " + a.equals(b));
System.out.println("a.equals(bAsObject): " + a.equals(bAsObject));
List<Money> prices = List.of(a);
System.out.println("prices.contains(b): " + prices.contains(b));
}
}Example explained
Line 1a.equals(b) resolves to equals(Money) at compile time because b's static type is Money, so the direct call looks correct.
Line 2Through an Object reference the compiler can only choose equals(Object), which Money never overrides, so reference comparison returns false.
Line 3prices.contains(b) fails for the same reason: collections always call equals(Object) and know nothing about the extra method.
Line 4Writing @Override above equals(Money) turns this silent bug into a compile error.
Important notes
Not everything should become a value carrier. If two instances with identical contents must stay distinguishable, such as an entity with an id and changing state, inherited reference equality is the correct behaviour and overriding it will collapse distinct objects into one Set element.
An array field is a trap: array equals and hashCode are reference-based, so two carriers holding the same contents in different arrays compare unequal unless you use Arrays.equals and Arrays.hashCode, or store an unmodifiable List instead.
Common mistakes
Overriding equals but leaving hashCode inherited: two equal objects get different hash codes, so map.get returns null for a key you just put in and a HashSet happily stores duplicates.
Declaring equals(MyType other) instead of equals(Object o): it compiles, direct calls return true, and every collection keeps comparing references because it calls equals(Object).
Including a mutable field in equals and hashCode and then mutating it while the object sits in a HashSet: the entry stays in its old bucket and cannot be found again, not even by the object itself.
Try it yourself
Change, predict, then run
Write a class Rgb with three int fields and no overrides, put two instances built from the same three numbers into a HashSet and print the size. Then add equals, hashCode and toString over those fields and print the size again to watch it fall from 2 to 1.
Open the Java workspaceCheck your understanding
You add equals(Object) to a data-carrying class, comparing its fields, but leave hashCode inherited from Object. Why can a HashMap still fail to find a key equal to one you stored?
- HashMap ignores equals and compares keys with ==
- The map keeps a snapshot of the key at insert time, so the lookup compares against a stale copy
- The lookup chooses a bucket from hashCode and only calls equals inside that bucket, so a different hash sends it to the wrong bucket
- equals is only used by collections when it is annotated with @Override
Show answer
Lookup is hash-first: the map narrows the search to one bucket using hashCode and never compares against keys in other buckets, which is why the contract demands that equal objects share a hash code. The first option is tempting because reference comparison does explain the failure before equals is overridden, but once equals compares fields the comparison would succeed if the lookup ever reached the stored key; the hash code is what stops it from getting there.