Generics, Equality, hashCode, and Immutability
Type-safe reusable code with generics, the equals/hashCode contract every class in a HashSet or HashMap must honor, and why immutable objects eliminate an entire category of bugs.
What you'll learn
- Write a simple generic class or method
- Override equals and hashCode correctly and consistently
- Design an immutable class and explain why immutability simplifies reasoning about code
Prerequisites
Explanation
Generics let a class or method work with any type while the compiler still enforces type safety at compile time. class Box<T> { private T value; T get() { return value; } void set(T v) { value = v; } } — a Box<String> only ever holds Strings, and the compiler rejects box.set(5) at compile time rather than producing a runtime ClassCastException later, which is exactly the class of bug generics exist to prevent. List<String>, Map<String, Integer>, and every other collection type from the earlier lesson are themselves generic classes — this is the same mechanism, not a special case.
Every Java class inherits a default equals() from Object that checks reference identity (==) — two separately-constructed Point(1,2) objects are !equals() unless you override it. Overriding equals() to compare field-by-field is necessary the moment you want "two objects with the same data" to count as equal, but it comes with a strict, easy-to-violate requirement: any class that overrides equals() must also override hashCode(), consistently — two objects that are .equals() to each other must return the same hashCode(). HashSet and HashMap rely on this contract internally: they use hashCode() to decide which internal bucket to look in first, then equals() to confirm a match within that bucket. Break the contract (override one but not the other, or make them disagree) and objects that are logically equal can silently fail to be found in a HashSet/HashMap — not a compile error, not an exception, just a lookup that mysteriously returns "not found" for an object that's clearly, by equals(), already there.
Immutability — designing a class so its state can never change after construction (every field final, no setters, and — critically — no method that hands out a direct reference to a mutable field, which would let a caller bypass the "immutable" guarantee entirely by mutating the shared object through that reference) — eliminates an entire category of bugs: an immutable object can be freely shared, cached, or used as a Map/Set key with total confidence its state won't change out from under whoever's using it. String and Java's boxed number types (Integer, Long, ...) are immutable for exactly this reason. Records (record Point(int x, int y) {}, introduced in Java 16) are Java's built-in tool for immutable data — the compiler generates a correct, consistent equals()/hashCode()/toString() for you, which is worth knowing precisely because manually keeping those three methods consistent by hand, as this lesson's exercises do, is exactly the error-prone work records exist to eliminate.
Example
The equals/hashCode-style contract modeled with JS: two 'equal' values must produce the same computed key, or a Set/Map-style lookup breaks.
class Point {
constructor(x, y) { this.x = x; this.y = y; }
equals(other) {
return other instanceof Point && this.x === other.x && this.y === other.y;
}
hashKey() { // models hashCode(): equal objects MUST produce the same key
return this.x + "," + this.y;
}
}
const p1 = new Point(1, 2);
const p2 = new Point(1, 2); // a different object, but equal by value
console.log(p1.equals(p2)); // true
console.log(p1.hashKey() === p2.hashKey()); // true -- the contract holds
// A Map keyed by hashKey() correctly treats p1 and p2 as "the same" entry:
const seen = new Map();
seen.set(p1.hashKey(), p1);
console.log(seen.has(p2.hashKey())); // true -- found, because the contract was honoredTry it yourself
Break the contract: make hashKey() return Math.random() instead, and see how has() can no longer reliably find an equal point.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Model a generic-style Box: class Box with a constructor(value), get(), and set(newValue) that throws if typeof newValue !== typeof this.value (modeling a generic type parameter's compile-time guarantee as a runtime check).
Checks: get() returns the constructed value · set() updates the value when the type matches · set() rejects a mismatched type
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Write equalsAndHash(a, b) style helpers: pointEquals(p1, p2) (true if same x AND y) and pointHashKey(p) (a string combining x and y) for plain {x, y} objects. Then write findMatch(points, target) that uses pointHashKey to find and return the FIRST point in points whose hash key matches target's hash key (or null if none), demonstrating the contract in action.
Checks: pointEquals correctly compares by value · pointHashKey honors the equals/hashCode contract · findMatch locates a matching point · findMatch returns null when there's no match
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Common mistakes
- Overriding equals() but not hashCode() (or vice versa) -- this breaks the contract silently; the class will compile fine and then behave incorrectly inside any HashSet or HashMap.
- Making an 'immutable' class that hands out a direct reference to a mutable field (e.g. a getter returning the actual internal List instead of a defensive copy) -- callers can mutate that returned List and silently break the immutability guarantee.
- Assuming generics provide any runtime type information -- Java's generics use type erasure, meaning `List<String>` and `List<Integer>` are the exact same class at runtime; the safety generics provide is entirely a compile-time guarantee.
Knowledge check
Takeaway
Generics give you compile-time type safety with no runtime type information (due to erasure); equals() and hashCode() must always be overridden together and stay consistent, or hash-based collections silently misbehave; immutability removes the need to defend against mutation entirely.
Summary
Generics (Box<T>) enforce type safety at compile time via erasure, not at runtime. Overriding equals() requires overriding hashCode() consistently, since HashSet/HashMap rely on both. An immutable class has all-final fields, no setters, and never exposes a direct reference to a mutable field.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.