Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Java temurin-25.0.4+101.0.LTS and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Money and a program that uses it

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());
    }
}
The program, then javap's list of Money's members Python · money_session.py
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 always Record. A record cannot name a superclass of its own, so extends after 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, not getPaise().
  • public final toString, hashCode and equals. The specification defines them: two records are equal when they are of the same record class and every component is equal (reference components by equals, primitive ones by the wrapper’s compare), and toString names the class and every component, as in Money[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:

Timeline · 5 events in order The releases that shaped Java records Walk through the releases in order. The last two changed what a record can do, not what it is.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. Question 1 of 2 For record Seat(String coach, int number), how do you read the coach of a seat s?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: s.coach()

    javap lists private final long paise and public long paise() for Money: a private field and a public accessor of the same name for every component.

  2. Question 2 of 2 javap lists final class Money extends java.lang.Record. What does that mean for Money?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No class can extend Money, and Money can extend no class of its own choosing

    The compiler makes every record final with Record as its superclass. It may still implement interfaces and add methods.

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:

Two record rules broken on purpose

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;
}
javac refuses both Python · rules_session.py
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):

Interactive · stage by stage Every Money passes through the compact constructor Step through new Money("12.50"), then new Money(-1), and watch where each value is checked and stored.
Every Money passes through the compact constructor, step 7 of 7. Showing new Money("12.50"), parse the text ("12.50" becomes 1250 paise), this(parsed), the compact constructor (throws: negative amount), the field is assigned (paise = 1250), a finished Money (none: the check threw). In focus: the compact constructor.new Money("12.50")the second constructorparse the text"12.50" becomes 1250 paisethis(parsed)hand over to the canonicalthe compact constructorthrows: negative amountthe field is assignedpaise = 1250a finished Moneynone: the check threwnegative amount: -1 paise

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The call. new Money("12.50") runs Money(String rupees), the record's second constructor, because its argument is a String.
  2. Parse first. Since JDK 25 statements may come before this(...), so the constructor first turns "12.50" into 1,250 paise with BigDecimal.
  3. 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.
  4. The check runs. The compact constructor's body runs with the parameter paise = 1250. It is not negative, so the body finishes without throwing.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 Which line makes this record fail to compile?

    Read the code, then choose one answer.

    record Name(String value) {
        Name {
            if (value == null) throw new IllegalArgumentException();  // (1)
            value = value.strip();                                     // (2)
            this.value = value.toUpperCase();                          // (3)
        }
    }
    Show the answer: Question 1 of 2

    Answer: (3), because a compact constructor must not assign the field

    In a compact constructor the parameter may be given a new value, as line (2) does, and the field is assigned from it after the body. javac reports line (3) as an assignment to a final variable.

  2. Question 2 of 2 What happens with new Money("-1.00"), given money/Money.java?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Money(String) parses -100 paise, calls this(-100), and the compact constructor throws for the negative amount

    -1.00 rupees is -100 paise. this(-100) reaches the compact constructor, which throws IllegalArgumentException with negative amount: -100 paise.

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:

A basket that keeps the caller's list, and one that copies it

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
    }
}
The caller adds an item after both baskets were made Java · basket/Main.java
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:

Interactive · step by step Which list each basket holds Step through basket/Main.java. The boxes on the left are main's variable and the records' items fields.
Which list each basket holds, step 7 of 7. Showing items, leaky.items, safe.items, leakyTwin.items, the caller's ArrayList (tea, rusk, samosa), List.copyOf's copy, List.of("tea", "rusk"). In focus: safe.items, List.copyOf's copy.refers tothe same listits own copyrefers toitemsmain's variableleaky.itemsa final fieldsafe.itemsa final fieldleakyTwin.itemsa final fieldthe caller's ArrayListtea, rusk, samosaList.copyOf's copytea, ruskList.of("tea", "rusk")tea, ruskadd throws, nothing changes

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The caller's list. main builds an ArrayList holding tea and rusk.
  2. LeakyBasket keeps it. new LeakyBasket("Asha", items) has no compact constructor, so its final field receives the caller's list itself.
  3. Basket copies it. Basket's compact constructor runs items = List.copyOf(items) on the parameter, so the field receives an unmodifiable copy.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

readings/Readings.java Java · readings/Readings.java
/** An array component: the generated equals compares the array references, not their contents. */
record Readings(String sensor, int[] values) {
}
Two records with equal arrays Java · readings/Main.java
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

  1. Question 1 of 2 record Basket(List<String> items) {} is created from a caller's ArrayList, and the caller then adds an element to that list. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The basket's items change, and so do its equals and hashCode results

    A record is only shallowly immutable. items = List.copyOf(items) in a compact constructor prevents it, which is the one line LeakyBasket lacks.

  2. Question 2 of 2 record Readings(String sensor, int[] values) {} and two records built from two arrays holding 21, 22, 23. Is a.equals(b) true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No, because the generated equals compares the arrays with their own equals, which tests identity

    Prefer a List<Integer> component copied with List.copyOf. If an array is necessary, copy it with clone() and write equals, hashCode and toString with Arrays.equals, Arrays.hashCode and Arrays.toString.

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 as ofRupees.
  • 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

  1. Question 1 of 2 Why is a record declared inside a class implicitly static?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: So that it never carries a hidden reference to an enclosing object, which would be state outside its header

    An inner class keeps a reference to its enclosing object, which would quietly add state that equals and toString ignore. A record never gets one.

  2. Question 2 of 2 An explicit accessor of record Money(long paise) returns paise rounded to whole rupees. Which promise of a record does it break?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: That a new record built from the accessors' results equals the record you started from

    Explicit accessors and equals are allowed, but they must keep the record's promise that the components are the whole state, read back as they were given.

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:

Three questions decide between a record and a class; a yes to all three means a record, and any no means a class.1. Is it nothing more thana fixed set of values?2. Do the values staythe same once it exists?3. No superclass needed,and its API is its fields?a recorda classyesyesyesnonono

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.

  1. 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.
  2. Do the values stay the same once the object exists? If not, use a class: a record's fields are final.
  3. 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.

A record and a regular class
Criterion recordclass
equals, hashCode, toString GeneratedHand-written
Fields Components, finalAny you choose
Extends a class NeverCan
API differs from fields NeverCan
Filled after creation NeverWith setters
When to choose Plain valuesHidden 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

  1. Question 1 of 2 For which of these is a record a good fit?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The result of a search, returned from a method and never changed
    • A money amount in paise with arithmetic methods

    Records fit plain values that never change. State that changes, a superclass of your own or an API that differs from the fields means a class.

  2. Question 2 of 2 A library creates every object with a no-argument constructor and then calls a setter for each field. Can it build a record?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No, because a record has no setters and its fields cannot be assigned after construction

    The row "Filled after creation" of the comparison table decides this case: a record is complete the moment its constructor returns.

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 not null or 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.
The starter records

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) {
}
The sample checks against the starter records Java · booking/BookingChecks.java
// 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.

  1. Question 1 of 11 Which of these does the compiler generate for record Seat(String coach, int number)?

    Choose every answer that is right.

    Show the answer: Question 1 of 11

    Answer:

    • A private final field for each component
    • The accessors coach() and number()
    • equals, hashCode and toString computed from both components

    A record header gives fields, a canonical constructor, accessors and equals, hashCode and toString, and nothing that changes the record afterwards.

  2. Question 2 of 11 What does IO.println(new Money(5_750)) print?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: Money[paise=5750]

    The generated toString is meant for people. Read values with the accessors, never by parsing the text.

  3. Question 3 of 11 Are new Seat("B2", 34) and new Seat("B2", 34) equal, and do they have equal hash codes?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: Both, because the generated equals and hashCode read every component

    That is why a HashSet of seats can find a repeated seat in the practice task without a line of equals or hashCode written by hand.

  4. Question 4 of 11 What may the body of a compact constructor do?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Check the parameters and give them new values before they are stored

    The compact form keeps the body for checks and normalisation; the assignments are the compiler's.

  5. Question 5 of 11 Before JDK 25, why would Money(String) have needed a helper method to parse its text?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: this(...) had to be the very first statement, so the parsing could not come before it

    The helper would have computed the paise inside the this(...) call itself, the only place an expression could go before JDK 25.

  6. Question 6 of 11 What does the Record documentation mean when it calls records shallowly immutable?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: The fields are final, but a component can refer to an object that can still change

    Immutable at the top level only: the record's own fields never change, while the objects they refer to may, unless the record copies them or they are immutable themselves.

  7. Question 7 of 11 record Order(String id, List<String> lines) must stay immutable whatever its callers do. Which compact constructor body does that?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: lines = List.copyOf(lines);

    Copy on the way in with an unmodifiable copy; the generated accessor can then hand the field out safely.

  8. Question 8 of 11 Which of these can a record not do?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Extend a class

    A record may add methods, static members, constructors and interfaces, and is closed to everything that would add state: superclasses, instance fields and setters.

  9. Question 9 of 11 ExtraField declares private int discount; beside its component. What does javac say?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: field declaration must be static, and it suggests replacing the field with a record component

    Static fields are allowed in a record; instance fields beyond the components are not, which keeps equals, hashCode and toString complete.

  10. Question 10 of 11 A cart whose items change while the customer shops. Record or class?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: A class, because the state changes after creation

    The diagram's second question decides it: do the values stay the same once the object exists? For a cart they do not.

  11. Question 11 of 11 A Temperature type keeps kelvin internally and must offer only celsius() and fahrenheit() to callers. Record or class?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: A class, because a record makes every component public through its accessor

    The third question of the diagram: is its API simply its fields? When the answer is no, use a class.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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, hashCode and toString.
  • 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, whose equals compares 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.