JAVA / RECORDS, SEALED TYPES AND ENUMS
Choosing enums, records or full classes
Decide between an enum, a record and a full class by asking whether the instance set is closed, whether identity is the data, and whether state changes.
What you will learn
- Pick an enum only when the complete set of instances is fixed at compile time.
- Pick a record when an object's identity is exactly its component values.
- Pick a class when a field must change or equality must ignore part of the state.
- Rewrite mutable value holders as records to keep map and set lookups reliable.
Understanding Choosing enums, records or full classes
Three forms can hold the same data, so the choice is settled by two questions rather than by taste. First, can you write down every instance the type will ever have, with no room for others? If so it is an enum, because the compiler can then guarantee the set is closed. If not, ask whether an instance is nothing more than its field values, so that two objects with identical fields are interchangeable anywhere in the program: that is a record, and whatever is left over is a class.
Each form is a different bundle of promises. An enum promises one object per constant, so == compares constants correctly, and it unlocks EnumSet, EnumMap and switch over a known set; the price is that adding an instance means editing source. A record promises final components, accessors named after them, and equals, hashCode and toString built from all of them; the price is that you cannot hide the representation, exclude a field from equality, or extend a class. A plain class promises none of that and therefore permits all of it: mutable fields, equality over a subset of state, and an internal layout you can change later without touching callers.
The model behind those two questions is the difference between a value and an entity. A value has no life cycle: 250 cents in euros is the same value wherever it appears, which is why records are safe as map keys, inside sets, and shared across threads. An entity has a life cycle - an account, a socket, a game board - and its identity survives every change to its state, so its equality must rest on something stable while its fields stay free to change. When you catch yourself wanting a record with a setter, or an enum constant that stores per-user data, that is the signal you are modelling an entity and need a class.
import java.util.HashSet;
import java.util.Set;
public class ShapeOfData {
// Closed set of instances -> enum
enum Currency { EUR, USD }
// Value is exactly its components -> record
record Money(long cents, Currency currency) {}
// State changes over time, identity is the object -> class
static final class Wallet {
private final Currency currency;
private long cents;
Wallet(Currency currency) {
this.currency = currency;
}
void deposit(Money m) {
if (m.currency() != currency) {
throw new IllegalArgumentException("wrong currency");
}
cents += m.cents();
}
Money balance() {
return new Money(cents, currency);
}
}
public static void main(String[] args) {
System.out.println("one object per constant: "
+ (Currency.EUR == Currency.valueOf("EUR")));
Money x = new Money(250, Currency.EUR);
Money y = new Money(250, Currency.EUR);
System.out.println("equal by components: " + x.equals(y)
+ ", same object: " + (x == y));
Set<Money> distinct = new HashSet<>();
distinct.add(x);
distinct.add(y);
System.out.println("set size: " + distinct.size());
Wallet mine = new Wallet(Currency.EUR);
mine.deposit(x);
mine.deposit(y);
System.out.println("balance: " + mine.balance());
Wallet yours = new Wallet(Currency.EUR);
yours.deposit(new Money(500, Currency.EUR));
System.out.println("same balance, equal wallets: " + mine.equals(yours));
}
}The form follows the data: a closed set of instances is an enum, a value defined entirely by its components is a record, and changing state or a hidden representation needs a class.
Worked examples
A mutable holder cannot be a key
Shows the concrete failure that pushes a value-like type from a class to a record.
import java.util.HashMap;
import java.util.Map;
public class KeyChoice {
static final class MutablePoint {
int x;
int y;
MutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
@Override public boolean equals(Object o) {
return o instanceof MutablePoint p && p.x == x && p.y == y;
}
@Override public int hashCode() {
return 31 * x + y;
}
}
record Point(int x, int y) {}
public static void main(String[] args) {
Map<MutablePoint, String> bad = new HashMap<>();
MutablePoint k = new MutablePoint(1, 2);
bad.put(k, "start");
k.x = 9;
System.out.println("lookup after mutation: " + bad.get(k));
System.out.println("value still stored: " + bad.containsValue("start"));
Map<Point, String> good = new HashMap<>();
good.put(new Point(1, 2), "start");
System.out.println("record lookup: " + good.get(new Point(1, 2)));
}
}Example explained
Line 1bad.put(k, "start") files the entry under hashCode 31*1+2 = 33, which masks to bucket 33 & 15 = 1 in the default 16-slot table.
Line 2k.x = 9 changes the hash to 281, so get(k) now searches bucket 281 & 15 = 9 and finds nothing.
Line 3containsValue("start") scans every entry and returns true, proving the entry is still in the map but unreachable by its own key.
Line 4Point has final components by construction, so a record key can never drift out of its bucket - that is why a value belongs in a record.
An entity keeps its identity while state changes
Shows the mirror case, where mutable state plus identity equality rules out a record.
import java.util.HashSet;
import java.util.Set;
public class IdentityChoice {
static final class Account {
private final String id;
private long balance;
Account(String id, long balance) {
this.id = id;
this.balance = balance;
}
void credit(long amount) {
balance += amount;
}
long balance() {
return balance;
}
@Override public boolean equals(Object o) {
return o instanceof Account a && a.id.equals(id);
}
@Override public int hashCode() {
return id.hashCode();
}
}
record Snapshot(String id, long balance) {}
public static void main(String[] args) {
Set<Account> open = new HashSet<>();
Account acc = new Account("AC-1", 100);
open.add(acc);
acc.credit(50);
System.out.println("same account after change: " + open.contains(acc));
System.out.println("balance now: " + acc.balance());
Snapshot before = new Snapshot("AC-1", 100);
Snapshot after = new Snapshot("AC-1", 150);
System.out.println("snapshots equal: " + before.equals(after));
}
}Example explained
Line 1balance is not final, and a record component always is, so the mutability alone decides that Account is a class.
Line 2hashCode uses id only, so credit(50) cannot move the account inside the HashSet and contains(acc) stays true.
Line 3If Account were a record, balance would join equals, and the stored entry would stop matching the credited account.
Line 4Snapshot compares both components on purpose: two readings of one account at different balances are genuinely different values.
A closed set is enforced by an enum, not by a wrapper
Shows what a record over a String cannot promise about the set of legal instances.
import java.util.EnumSet;
import java.util.Set;
public class FixedSetChoice {
enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }
record DayName(String value) {}
public static void main(String[] args) {
Set<Day> weekend = EnumSet.of(Day.SAT, Day.SUN);
System.out.println("weekend: " + weekend);
System.out.println("MON is weekend: " + weekend.contains(Day.MON));
DayName typo = new DayName("Satrudai");
System.out.println("record accepts: " + typo.value());
try {
Day.valueOf("Satrudai");
} catch (IllegalArgumentException e) {
System.out.println("enum rejects: " + e.getMessage());
}
}
}Example explained
Line 1EnumSet.of accepts only declared constants and iterates in declaration order, so the set prints as [SAT, SUN].
Line 2new DayName("Satrudai") compiles because the record only promises to carry a String; nothing constrains which String.
Line 3Day.valueOf("Satrudai") throws IllegalArgumentException, and writing Day.Satrudai would not compile at all.
Line 4That compile-time closure is the whole reason to choose an enum over a one-component record.
Important notes
Every record component takes part in equals, hashCode and toString; needing to exclude one field from equality rules out a record by itself.
Needing a common supertype does not force a full class: a record can implement interfaces, it just cannot extend one, and every enum already extends java.lang.Enum.
Common mistakes
Using a mutable holder with field-based equals as a map key: mutating a field after insertion changes the hash, lookups return null, and the entry stays in the map unreachable.
Modelling a fixed set of options as a record wrapping a String, so new Status("actve") compiles happily and only shows up later as a branch that never matches.
Choosing a record for an entity such as an order with a status: status joins equals, so updating it yields an object the existing set or map no longer recognises as the same order.
Try it yourself
Change, predict, then run
Write a Reservation class with a final id and a mutable confirmed flag whose equality uses id only, plus a record ReservationSnapshot(String id, boolean confirmed) returned by a snapshot() method. Print that the reservation is still found in a HashSet after being confirmed, while the snapshots taken before and after are not equal.
Open the Java workspaceCheck your understanding
A Session has a sessionId that never changes and a lastSeen timestamp refreshed on every request, and live sessions are kept in a HashSet. Which form fits?
- A class with equals and hashCode based only on sessionId
- A record Session(String sessionId, Instant lastSeen), since a session is just data
- An enum, because a session is always in one of a known set of states
- A record with one mutable component so lastSeen can be updated in place
Show answer
The session is an entity: its identity must survive a change of state, which requires equality over sessionId only and a lastSeen field that can be reassigned - only a class gives both. The record option looks right because a session is mostly data, but a record's equals and hashCode cover every component, so refreshing lastSeen would create a value the existing HashSet entry no longer matches; and option four does not even compile, since record components are always final.