Java Module 5 – Classes, objects, records and enums
Java records for immutable data
Declare Java records, validate and normalise their components in compact constructors, copy mutable parts, and decide when a class fits better.
What you will learn
- Declare records with components, compact constructors and static factory methods, and list what the compiler generates
- Validate and normalise record components in a compact constructor
- Copy mutable components so that a record stays immutable
- Choose between a record and a regular class
Before you start
What you’ll learn · Before you start
What you will learn
- Declare records with components, compact constructors and static factory methods, and list what the compiler generates
- Validate and normalise record components in a compact constructor
- Copy mutable components so that a record stays immutable
- Choose between a record and a regular class
Before you start
On this page
The Point class of the lesson on equals and hashCode took 22 lines for two fields, and it still had no
toString. A record: A class declared with record whose state is the components named in its header. The compiler writes a private final field and a public accessor for each, a canonical constructor, and equals, hashCode and toString; the class is final and extends java.lang.Record. is a class that is nothing more than its data: you write the name
and the record component: One of the names and types in a record's header, such as long paise in record Money(long paise). Each gives the record a private final field and a public accessor method with the same name. in one line, record Point(int x, int y) { }, and the compiler writes the rest: a private final
field for each component, a constructor that takes them all, a public accessor method named after each one, and
equals, hashCode and toString computed from all of them. JEP 395 describes records as transparent carriers for
immutable data, and they have been a standard part of Java since JDK 16.
What records do not do is think for you. A record still needs you to validate its components, to copy any mutable part, and to know when a regular class is the better choice. This lesson covers all three, with a money record whose compiled form you can see in full, a record that leaks its list until it copies it, and a booking for you to build.
One header declares fields, a constructor, accessors and three methods
Here is a record for an amount of money, kept in paise so that sums stay exact, with a validating constructor, a second constructor, a static factory method and an instance method:
Java (Money.java) · money/Money.java
import java.math.BigDecimal;
/** An amount of money in paise: never negative. */
record Money(long paise) {
/** The compact canonical constructor: it checks the component, then the field is assigned for you. */
Money {
if (paise < 0) {
throw new IllegalArgumentException("negative amount: " + paise + " paise");
}
}
/** "12.50" rupees as text. Since JDK 25, statements may come before this(...). */
Money(String rupees) {
long parsed = new BigDecimal(rupees).movePointRight(2).longValueExact();
this(parsed);
}
static Money ofRupees(long rupees) {
return new Money(Math.multiplyExact(rupees, 100));
}
Money plus(Money other) {
return new Money(Math.addExact(paise, other.paise));
}
} Java (Main.java) · money/Main.java
void main() {
Money fare = Money.ofRupees(45);
Money tip = new Money("12.50");
Money total = fare.plus(tip);
IO.println("total: " + total); // the generated toString
IO.println("total.paise(): " + total.paise()); // the generated accessor
IO.println("equal to 5750? " + total.equals(new Money(5_750)));
IO.println("same hash code? " + (total.hashCode() == new Money(5_750).hashCode()));
try {
new Money(-1);
} catch (IllegalArgumentException e) {
IO.println("new Money(-1): " + e.getMessage());
}
} from session import run
# The program, then javap: what the compiler put into Money.class.
run("""
java money/Main.java
javac -d classes money/Money.java
javap -p -cp classes Money
""") Output
# openjdk version "25.0.4.1" LTS
$ java money/Main.java
total: Money[paise=5750]
total.paise(): 5750
equal to 5750? true
same hash code? true
new Money(-1): negative amount: -1 paise
$ javac -d classes money/Money.java
$ javap -p -cp classes Money
Compiled from "Money.java"
final class Money extends java.lang.Record {
private final long paise;
Money(long);
Money(java.lang.String);
static Money ofRupees(long);
Money plus(Money);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public long paise();
}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 money_session.py
javap -p lists what the compiler put into Money.class, which is the honest answer to “what does a record
generate”:
final class Money extends java.lang.Record. Every record is final, and its superclass is alwaysRecord. A record cannot name a superclass of its own, soextendsafter the header is a syntax error.private final long paise, the field for the component.Money(long), the canonical constructor: The constructor of a record whose parameters are exactly its components, in order. The compiler writes one unless the record declares it, and every other constructor of the record must end up calling it., whose parameters match the header. Here it is the compact constructor of the source file.public long paise(), the accessor. It is named after the component, notgetPaise().public finaltoString,hashCodeandequals. The specification defines them: two records are equal when they are of the same record class and every component is equal (reference components byequals, primitive ones by the wrapper’scompare), andtoStringnames the class and every component, as inMoney[paise=5750].
The Record documentation adds one caution about that last one: the format of the generated toString may change,
so never parse it to recover the values. Records took three releases to become standard, and two later releases
added to what they can do:
- JDK 14
Records as a preview feature preview
JEP 359 proposed records, and JDK 14 shipped them as a preview feature, usable only with preview features switched on.
- JDK 15
A second preview preview
In response to feedback, JEP 384 refined the design, and JDK 15 shipped it as a preview feature for a second time.
- JDK 16
Records become standard standard
JEP 395 made records a standard part of the language: no flag is needed, and every later release has them.
- JDK 21
Record patterns new syntax
JEP 440 made record patterns standard: instanceof Money(long paise) tests the type and names the component in one step.
- JDK 25
Statements before this(...) new syntax
JEP 513, flexible constructor bodies, lets a constructor run statements before this(...), so Money(String) can parse its text first and then hand over.
The timeline has 5 events, from JDK 14 to JDK 25.
Checkpoint
Check: What a record header generates
A compact constructor validates and normalises, then the fields are assigned
The block Money { … } is a compact constructor: A canonical constructor written without a parameter list, as Money { … }. Its body may check the parameters and give them new values, and the fields are assigned from them after the body has run; assigning a field there does not compile.. It declares no
parameter list, because the parameters are the components, and it never assigns the fields: the compiler assigns
every field from its parameter after the body has run. That leaves the body for exactly two jobs.
- Validate: throw an exception for values the record must never hold.
new Money(-1)fails with the message the constructor chose, so no negative amount can exist anywhere in the program. - Normalise: give a parameter a new value before it is stored, for example
name = name.strip();. Assigning the parameter is allowed; assigning the field is not.
The rules are checked by javac:
Java (AssignsField.java) · rules/AssignsField.java
/** A compact constructor may change the parameter, but never assign the field. */
record AssignsField(long paise) {
AssignsField {
this.paise = Math.abs(paise);
}
} Java (ExtraField.java) · rules/ExtraField.java
/** The record header is the whole state: no other instance fields. */
record ExtraField(long paise) {
private int discount;
} from session import run
run("""
!javac -d classes rules/AssignsField.java
!javac -d classes rules/ExtraField.java
""") Output
# openjdk version "25.0.4.1" LTS
$ javac -d classes rules/AssignsField.java
rules/AssignsField.java:4: error: cannot assign a value to final variable paise
this.paise = Math.abs(paise);
^
1 error
[exit status 1]
$ javac -d classes rules/ExtraField.java
rules/ExtraField.java:3: error: field declaration must be static
private int discount;
^
(consider replacing field with record component)
1 error
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rules_session.py
AssignsField writes this.paise inside the compact constructor, which the specification forbids; javac reports
it as an assignment to a final variable. ExtraField declares an instance field next to the components. A record’s
state is its header and nothing else, so only static fields are allowed, and the compiler suggests turning the field
into a component.
Other constructors and factories. A record may declare more constructors, but each one has to call another
constructor of the record, so in the end every object passes through the canonical one and its checks. Money(String rupees) parses "12.50" into 1,250 paise and then calls this(parsed). Step through that call, then
new Money(-1):
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The call. new Money("12.50") runs Money(String rupees), the record's second constructor, because its argument is a String.
- Parse first. Since JDK 25 statements may come before this(...), so the constructor first turns "12.50" into 1,250 paise with BigDecimal.
- Hand over. this(parsed) calls the canonical constructor with 1,250. Every constructor of a record has to end up there, so no Money skips its checks.
- The check runs. The compact constructor's body runs with the parameter paise = 1250. It is not negative, so the body finishes without throwing.
- The field is set. Only after the body has run does the compiler's code assign the field from the parameter. The body itself never assigns this.paise.
- A Money exists. The object is finished, Money[paise=1250], and nothing can change it afterwards: the field is final and there is no setter.
- new Money(-1). new Money(-1) calls the canonical constructor directly. The check finds -1 < 0 and throws, so the field is never assigned and no object exists, as the run's last line shows.
Before JDK 25 the call to this(...) had to be the very first statement (JDK 22 to 24 offered the change only as a
preview), which pushed the parsing into a helper method. JDK 25 allows statements before it (JEP 513, flexible
constructor bodies), as long as they do not use the object being built. A static factory method such as
Money.ofRupees(45) is the other way to add a named way of creating a record; whatever it computes, it creates the
record through a constructor, so the checks still run.
Checkpoint
Check: Compact and other constructors
A record is only shallowly immutable
The Record documentation calls a record “shallowly immutable”: its fields are final, but a component can refer to
a mutable object, and the record cannot stop anyone from changing that object. A list is the usual case:
Java (LeakyBasket.java) · basket/LeakyBasket.java
import java.util.List;
/** The field is final, but it is the caller's own list. */
record LeakyBasket(String owner, List<String> items) {
} Java (Basket.java) · basket/Basket.java
import java.util.List;
import java.util.Objects;
/** The compact constructor copies the list before the field is assigned. */
record Basket(String owner, List<String> items) {
Basket {
Objects.requireNonNull(owner, "owner");
items = List.copyOf(items); // assigns the parameter; the field gets the copy
}
} void main() {
List<String> items = new ArrayList<>(List.of("tea", "rusk"));
LeakyBasket leaky = new LeakyBasket("Asha", items);
Basket safe = new Basket("Asha", items);
LeakyBasket leakyTwin = new LeakyBasket("Asha", List.of("tea", "rusk"));
IO.println("equal before: " + leaky.equals(leakyTwin));
items.add("samosa"); // the caller changes its list after the records were made
IO.println("leaky: " + leaky);
IO.println("safe: " + safe);
IO.println("equal after: " + leaky.equals(leakyTwin) + " (a record's equality changed)");
try {
safe.items().add("chai");
} catch (UnsupportedOperationException e) {
IO.println("safe.items().add: UnsupportedOperationException");
}
} Output
equal before: true leaky: LeakyBasket[owner=Asha, items=[tea, rusk, samosa]] safe: Basket[owner=Asha, items=[tea, rusk]] equal after: false (a record's equality changed) safe.items().add: UnsupportedOperationException
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Main.java
Step through the run and watch which list each record’s field leads to:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The caller's list. main builds an ArrayList holding tea and rusk.
- LeakyBasket keeps it. new LeakyBasket("Asha", items) has no compact constructor, so its final field receives the caller's list itself.
- Basket copies it. Basket's compact constructor runs items = List.copyOf(items) on the parameter, so the field receives an unmodifiable copy.
- An equal twin. leakyTwin holds another list with tea and rusk. The generated equals compares the components with their own equals, so the run prints equal before: true.
- The caller adds a samosa. items.add("samosa") changes the caller's list, and leaky prints three items although nobody added one to it. safe still prints tea and rusk.
- Equality changed. leaky.equals(leakyTwin) is now false: the same record, compared with the same twin, gives another answer, as the run's equal after line shows.
- The copy refuses. safe.items().add("chai") throws UnsupportedOperationException, because the accessor returns the unmodifiable copy.
LeakyBasket gained a samosa that nobody added to it, and worse, its equality changed: it was equal to an identical
basket before the caller’s add and is not equal after. A record used as a HashMap key would be lost exactly as in
the lesson on hashCode. Basket fixes it with one line in its compact constructor, items = List.copyOf(items);,
which assigns the copy to the parameter, so the field receives the copy. The copy is unmodifiable, so returning it
from the accessor is safe too.
Arrays are a sharper version of the same problem, because the generated equals compares components with their own
equals, and an array’s equals is the identity test of Object:
/** An array component: the generated equals compares the array references, not their contents. */
record Readings(String sensor, int[] values) {
} void main() {
Readings a = new Readings("T1", new int[] {21, 22, 23});
Readings b = new Readings("T1", new int[] {21, 22, 23});
IO.println("a.equals(b): " + a.equals(b));
IO.println("Arrays.equals(a.values(), b.values()): " + Arrays.equals(a.values(), b.values()));
IO.println("a: " + a);
} Output
a.equals(b): false Arrays.equals(a.values(), b.values()): true a: Readings[sensor=T1, values=[I@1d082e88]
This output changes from run to run: The hexadecimal number after [I@ is the array's identity hash code, which can differ from one run to the next.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Main.java
The two records hold equal numbers and are still not equal, and toString prints the array as [I@ and a
hexadecimal identity hash instead of its contents. Prefer a List<Integer> component, copied with List.copyOf. If
an array is necessary, copy it in the compact constructor and in an explicit accessor with clone(), and write
equals, hashCode and toString yourself with Arrays.equals, Arrays.hashCode and Arrays.toString.
Trusting final fields to keep a record immutable
record Basket(String owner, List<String> items) {} keeps whatever list the caller passed, and the caller can still
change it. Copy in the compact constructor, items = List.copyOf(items);, and the record keeps a list of its own.
Checkpoint
Check: Mutable components of a record
A record body may add methods and interfaces, but no state
A record body can hold more than the header gives it:
- Instance methods, such as
plus, and static fields and methods, such asofRupees. - Interfaces:
record Money(long paise) implements Comparable<Money>is fine. Records can also be the cases of a sealed interface, which the module on inheritance and interfaces builds on. - Nested and local records. A record declared inside a class or a method is implicitly static, so it never carries a hidden reference to an enclosing object (JEP 395 gives exactly that reason).
- Explicit accessors and
equals, when you need them, but they must keep the record’s promise: building a new record from the accessors’ results must give an equal record. An accessor that silently clips or rounds a value breaks that rule; the specification shows accessors that clip their components and calls that record bad style.
And it cannot: extend a class, declare instance fields or instance initializers, have setters (its fields are final), or be abstract.
| A record can | A record cannot |
|---|---|
| Implement interfaces | Extend a class |
| Declare instance methods, static fields and static methods | Declare instance fields or instance initializers |
| Declare more constructors that call another one | Assign its fields after construction, so no setters |
| Be nested or local, and is then implicitly static | Be abstract |
Records also work with record patterns (JDK 21, JEP 440), which take a record apart in one step:
if (value instanceof Money(long paise)) tests the type and names the component at once. The module on advanced
language features covers them.
Checkpoint
Check: What a record body may hold
Choose a record for plain values and a class for changing state
Three questions decide between the two, and a “no” to any of them means a class:
Record or class? Any "no" means a class
Text description of the diagram
The diagram is a decision flow with three questions, one below the other. A "yes" leads to the next question, and after the third to a record. A "no" to any question leads to a class.
- Is it nothing more than a fixed set of values? If not, for example because it manages a connection or a resource, use a class.
- Do the values stay the same once the object exists? If not, use a class: a record's fields are final.
- Can it do without a superclass, and is its API simply its fields? If not, use a class: a record cannot extend a class, and its accessors are its components.
A record is nothing more than a fixed set of values, its values stay the same once it exists, and it has no
superclass of its own and no API beyond its components. A connection, a cart that changes or a type that hides its
fields fails one of the questions. In the table, “class” covers both a hand-written immutable class, such as the
Timetable of the previous lesson, and a class with a no-argument constructor and setters.
| Criterion | record | class |
|---|---|---|
| equals, hashCode, toString | Generated | Hand-written |
| Fields | Components, final | Any you choose |
| Extends a class | Never | Can |
| API differs from fields | Never | Can |
| Filled after creation | Never | With setters |
| When to choose | Plain values | Hidden state, or state that changes |
The last row matters when a library builds your objects. Some frameworks create an object with a no-argument constructor and then call setters; a record has no setters and its fields cannot be assigned after construction, so such a library needs a regular class, while libraries that call a constructor with all values work well with records.
JSON to Java Class (POJO) Generates classic Java classes with getters and setters from a JSON sample. Compare the result with a record that holds the same fields.Checkpoint
Check: Choosing a record or a class
Practise: records for a train booking
Build three records with these rules, all checked in compact constructors:
Passenger(String name, int age): the name is notnullor blank and is stored without surrounding spaces; the age is from 0 to 125.Seat(String coach, int number): the coach is not blank; the number is from 1 to 72.Booking(List<Passenger> passengers, List<Seat> seats): 1 to 6 passengers, exactly one seat per passenger, no seat twice, and both lists copied so that nobody can change the booking afterwards.
Java (Passenger.java) · booking/Passenger.java
/** A traveller. Rules: the name is not blank and is stored trimmed; the age is from 0 to 125. */
record Passenger(String name, int age) {
} Java (Seat.java) · booking/Seat.java
/** A seat. Rules: the coach is not blank; the number is from 1 to 72. */
record Seat(String coach, int number) {
} Java (Booking.java) · booking/Booking.java
import java.util.List;
/** Rules: 1 to 6 passengers, exactly one seat per passenger, no seat twice, and nobody can change the lists. */
record Booking(List<Passenger> passengers, List<Seat> seats) {
} // Sample checks for the booking records. Edit the three records, then run: java booking/BookingChecks.java
int passed = 0;
int total = 0;
void check(String what, boolean ok) {
total++;
if (ok) passed++;
IO.println((ok ? "pass " : "FAIL ") + what);
}
boolean refused(Runnable create) {
try {
create.run();
return false;
} catch (IllegalArgumentException e) {
return true;
}
}
void main() {
List<Passenger> people = new ArrayList<>(List.of(new Passenger("Asha", 34), new Passenger("Ravi", 8)));
List<Seat> seats = new ArrayList<>(List.of(new Seat("B2", 33), new Seat("B2", 34)));
Booking booking = new Booking(people, seats);
check("a valid booking is created", booking.passengers().size() == 2);
check("equal bookings are equal", booking.equals(new Booking(List.copyOf(people), List.copyOf(seats))));
check("an age of -1 is refused", refused(() -> new Passenger("Meera", -1)));
check("an age of 126 is refused", refused(() -> new Passenger("Meera", 126)));
check("a blank name is refused", refused(() -> new Passenger(" ", 30)));
check("a name is stored trimmed", new Passenger(" Meera ", 30).name().equals("Meera"));
check("seat number 0 is refused", refused(() -> new Seat("B2", 0)));
check("the same seat twice is refused",
refused(() -> new Booking(people, List.of(new Seat("B2", 33), new Seat("B2", 33)))));
check("two passengers with one seat are refused", refused(() -> new Booking(people, List.of(new Seat("B2", 33)))));
check("a booking without passengers is refused", refused(() -> new Booking(List.of(), List.of())));
people.add(new Passenger("Kiran", 61));
check("changing the caller's list does not change the booking", booking.passengers().size() == 2);
boolean locked;
try {
booking.seats().add(new Seat("B2", 35));
locked = false;
} catch (UnsupportedOperationException e) {
locked = true;
}
check("the seats list cannot be changed", locked);
IO.println(passed + " of " + total + " checks passed");
if (passed < total) System.exit(1);
} Output (exit status 1)
pass a valid booking is created pass equal bookings are equal FAIL an age of -1 is refused FAIL an age of 126 is refused FAIL a blank name is refused FAIL a name is stored trimmed FAIL seat number 0 is refused FAIL the same seat twice is refused FAIL two passengers with one seat are refused FAIL a booking without passengers is refused FAIL changing the caller's list does not change the booking FAIL the seats list cannot be changed 2 of 12 checks passed
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java BookingChecks.java
Edit the three records until java booking/BookingChecks.java prints 12 of 12 checks passed. Throw
IllegalArgumentException for a broken rule. In Booking, copy both lists first, then check the copies: checking
the caller’s lists before copying them leaves a moment in which another thread could still change them. For “no
seat twice”, a HashSet of the seats is enough, because the generated equals and hashCode of Seat compare coach and number,
which is exactly what the lesson on equals and hashCode took a whole class to write by hand.
Afterwards, practise the five ideas of this lesson as questions, from the header to the choice of a class. The idea you have struggled with most on this page leads, and each answer is explained the moment you give it:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does the compiler generate for a Java record?
For record Point(int x, int y), the compiler generates a private final field for each component, a canonical
constructor that takes the components in order and assigns them, a public accessor for each component named after it
(x(), not getX()), and public final equals, hashCode and toString computed from all components. The class is
implicitly final and extends java.lang.Record, so it cannot extend anything else, though it can implement interfaces.
You can add static members, instance methods and other constructors, which must call the canonical one. A good answer
adds the limits: a record cannot declare extra instance fields, its fields are final, and it is only shallowly
immutable, so a mutable component such as a list must be copied in a compact constructor, for example with
List.copyOf.
Two longer questions of this module start from this lesson: turning a widely used value class into a record, and a
record with a double component whose validation lets two odd values through.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your team wants to turn a widely used value class, with getters and hand-written equals and hashCode, into a record. Which behaviours can change for existing callers, and how would you check them before merging?
-
Mid to senior
record Percentage(double value) rejects values below 0 or above 100 in its compact constructor. Tests pass, but in production a HashSet holds both Percentage[value=0.0] and Percentage[value=-0.0], and a NaN slipped through validation. Explain both, and fix the record.
Key takeaways
- A record declares its components in its header; the compiler generates private final fields, a canonical
constructor, accessors named after the components, and final
equals,hashCodeandtoString. - Records are final, extend
java.lang.Record, can implement interfaces and cannot declare extra instance fields. - A compact constructor validates and normalises the parameters, and the compiler assigns the fields afterwards; every other constructor must call another constructor of the record, and since JDK 25 statements may come first.
- A record is shallowly immutable: copy list components with
List.copyOf, and avoid array components, whoseequalscompares references. - Do not parse a record’s
toString; its format may change. - Use a record for plain values and a class when the state must change, the class must extend another, or its API must differ from its fields.
Terms in this lesson
- canonical constructor
- The constructor of a record whose parameters are exactly its components, in order. The compiler writes one unless the record declares it, and every other constructor of the record must end up calling it.
- compact constructor
- A canonical constructor written without a parameter list, as Money { … }. Its body may check the parameters and give them new values, and the fields are assigned from them after the body has run; assigning a field there does not compile.
- record
- A class declared with record whose state is the components named in its header. The compiler writes a private final field and a public accessor for each, a canonical constructor, and equals, hashCode and toString; the class is final and extends java.lang.Record.
- record component
- One of the names and types in a record's header, such as long paise in record Money(long paise). Each gives the record a private final field and a public accessor method with the same name.
References
- JEP 395: Records (OpenJDK)
- The Java Language Specification, Java SE 25: 8.10 Record Classes (Oracle)
- Class Record (Java SE 25) (Oracle)
- Record Classes (Java Platform, Standard Edition Java Language Updates, Release 25) (Oracle)
- JEP 513: Flexible Constructor Bodies (OpenJDK)
- JEP 440: Record Patterns (OpenJDK)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress