On this page
Description
A lot of backend code is about data: an order, an amount of money, a payment that's a card or a bank transfer or a wallet. Before Java 16, a simple value class like Money meant writing a constructor, getters, equals, hashCode and toString by hand, around 50 lines where a bug could hide in any of them. And there was no way to say "a payment is exactly one of these three kinds, and nothing else".
Records (Java 16) and sealed classes and interfaces (Java 17) fix both. A record declares a value type in one line. A sealed type lists every kind of something, so the compiler can check that your code handles them all. Together they've changed how modern Java models data, and interviews ask exactly what they generate and what they don't.
Here's the path, from easiest to most technical:
- Records: a value type in one line, and what Java writes for you.
- The rules records follow, and the compact constructor.
- The two record traps: accessor names, and arrays.
- Sealed types: a closed list of subtypes.
- Putting them together, and what Java 21 adds.
Records
record Money(long cents, String currency) {}
From that one line, Java generates:
- a
private finalfield for each component, and a constructor taking them in order:new Money(250, "USD"); - an accessor method for each component, named exactly like it:
cents()andcurrency(), notgetCents(); equals(two records are equal when all their components are equal), a matchinghashCode, and a readabletoString:Money[cents=250, currency=USD].
So two Money(250, "USD") objects are equal and work correctly as HashMap keys or HashSet elements, with no code from you. You can still add methods, static fields and static factory methods, and a record can implement interfaces:
record Money(long cents, String currency) implements Comparable<Money> {
static final Money ZERO_USD = new Money(0, "USD");
Money plus(Money other) { return new Money(cents + other.cents, currency); }
public int compareTo(Money o) { return Long.compare(cents, o.cents); }
}
The rules, and the compact constructor
Records are deliberately limited, so that "a record is just its components" stays true:
- A record is final and can't extend another class (it already extends
java.lang.Record). It can implement interfaces. - A record can't have extra instance fields: all its state is in the header. Static fields are fine.
- Its fields are
final, so a record can't be changed after it's created.
To check or tidy the values, write a compact constructor: the record's name, no parameter list, and a body that runs before the fields are set:
record Money(long cents, String currency) {
Money {
if (cents < 0) throw new IllegalArgumentException("negative amount");
currency = currency.strip().toUpperCase(Locale.ROOT); // normalize: " usd" becomes "USD"
}
}
Reassigning a parameter inside it (currency = ...) changes the value that gets stored. That's also the place to copy mutable inputs, such as tags = List.copyOf(tags);, because a record holding the caller's ArrayList is only as immutable as that list.
The two record traps
Accessor names. Accessors are cents(), not getCents(). Libraries that look for JavaBeans-style getters (older versions of Jackson, some template engines and mappers) see a record as having no properties at all, and print blanks or empty JSON. Modern versions understand records, but check before switching your DTOs over.
Arrays as components. The generated equals compares each component with that component's own equals. For an array, that's identity:
record Batch(String id, int[] counts) {}
new Batch("b", new int[]{1, 2}).equals(new Batch("b", new int[]{1, 2})) // false!
A cache keyed on Batch would never get a hit. Use a List<Integer> component instead (lists compare their contents), or override equals and hashCode with Arrays.equals and Arrays.hashCode.
Local records, declared inside a method, are handy as named pairs in stream code: record CityTotal(String city, long cents) {}.
Sealed types
A sealed class or interface lists exactly which types may extend or implement it:
sealed interface Payment permits Card, BankTransfer, Wallet {}
record Card(String last4) implements Payment {} // records are final
record BankTransfer(String iban) implements Payment {}
record Wallet(String provider) implements Payment {}
Nothing outside that list can implement Payment, and the compiler knows that every Payment is one of these three. The rules:
- Each permitted subtype must say how its branch continues:
final(no more subtypes; records and enums are final automatically),sealed(with its ownpermitslist), ornon-sealed(open to anyone again). Leaving it out is a compile error. non-sealedreopens just that branch. IfWalletwere anon-sealed class, anyone could extendWallet, butPaymentitself would still have exactly three direct kinds.- The permitted types must live in the same module (or the same package, if you don't use modules).
Putting them together
Sealed interfaces plus records are a clean way to model "one of several kinds, each with its own data". The payoff is a switch that the compiler checks covers every kind, so adding a fourth payment type flags every place that needs updating:
long fee = switch (payment) { // Java 21: pattern matching for switch
case Card c -> 30 + amount * 29 / 1000;
case BankTransfer b -> 25;
case Wallet w -> amount * 35 / 1000;
}; // no default needed: the sealed type is covered
Pattern matching for switch was only a preview in Java 17 and became final in Java 21 (the Java 21 module covers it). On Java 17 you'd write an if/instanceof chain; the sealed declaration still documents and enforces the closed set.
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| An array as a record component | equal-looking records aren't equals | a List, or override equals/hashCode |
Calling getCents() on a record | doesn't exist | cents(); check libraries that expect getters |
| Adding an instance field to a record | doesn't compile | put it in the header, or compute it in a method |
record X(...) extends Base | doesn't compile | implement an interface instead |
| Storing a caller's mutable list | the record can change underneath you | List.copyOf in the compact constructor |
A permitted subtype without final/sealed/non-sealed | doesn't compile | records are final; classes need a modifier |
Expecting an exhaustive switch on Java 17 | needs preview flags | Java 21, or an instanceof chain |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. Why are two records with equal int[] components not equal?
Show answer
The generated equals compares each component with its own equals, and an array's equals checks identity. Use a List component, or override equals and hashCode with Arrays.equals and Arrays.hashCode.
2. What does currency = currency.strip().toUpperCase(Locale.ROOT); inside a compact constructor do?
Show answer
It changes the parameter before the fields are set, so the record stores the normalized value. new Money(100, " usd") holds "USD", and it's equal to new Money(100, "USD").
3. A template shows blanks for every field of a record that replaced an old JavaBean. Why?
Show answer
The template engine looks for getters like getName(), and record accessors are named name(). Use a version of the library that understands records, or keep a class with getters for that boundary.
4. class Circle implements Shape {}, where Shape is sealed and permits Circle, doesn't compile. Why?
Show answer
Every permitted subtype must say how its branch continues: final, sealed or non-sealed. Declare final class Circle, or make it a record.
Key takeaways
- A record generates a constructor, accessors named after the components,
equals,hashCodeandtoString. - Records are final, can't extend classes, and can't add instance fields; validate, normalize and copy in the compact constructor.
- Record equality compares components with their own
equals, so arrays compare by identity. - Sealed types list their subtypes; each permitted subtype is
final,sealedornon-sealed. - Sealed interfaces plus records model "one of several kinds"; the exhaustive
switchover them is Java 21.