Java · Java 17 Features (9 to 17)

Java 17: Records and Sealed Classes

What a record generates and what it forbids, compact constructors that validate and normalize, sealed hierarchies and their final, sealed and non-sealed subclasses - and what Java 21 adds on top.

  • 6 min read
  • 12 practice questions
  • 1 graded problem
On this page
  1. Description
  2. Records
  3. The rules, and the compact constructor
  4. The two record traps
  5. Sealed types
  6. Putting them together
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

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:

  1. Records: a value type in one line, and what Java writes for you.
  2. The rules records follow, and the compact constructor.
  3. The two record traps: accessor names, and arrays.
  4. Sealed types: a closed list of subtypes.
  5. Putting them together, and what Java 21 adds.

Records

record Money(long cents, String currency) {}

From that one line, Java generates:

  • a private final field 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() and currency(), not getCents();
  • equals (two records are equal when all their components are equal), a matching hashCode, and a readable toString: 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 own permits list), or non-sealed (open to anyone again). Leaving it out is a compile error.
  • non-sealed reopens just that branch. If Wallet were a non-sealed class, anyone could extend Wallet, but Payment itself 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

MistakeWhat happensDo this instead
An array as a record componentequal-looking records aren't equalsa List, or override equals/hashCode
Calling getCents() on a recorddoesn't existcents(); check libraries that expect getters
Adding an instance field to a recorddoesn't compileput it in the header, or compute it in a method
record X(...) extends Basedoesn't compileimplement an interface instead
Storing a caller's mutable listthe record can change underneath youList.copyOf in the compact constructor
A permitted subtype without final/sealed/non-sealeddoesn't compilerecords are final; classes need a modifier
Expecting an exhaustive switch on Java 17needs preview flagsJava 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, hashCode and toString.
  • 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, sealed or non-sealed.
  • Sealed interfaces plus records model "one of several kinds"; the exhaustive switch over them is Java 21.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

A record in full

record Money(long cents, String currency) implements Comparable<Money> {
    static final Money ZERO_USD = new Money(0, "USD");     // static fields OK
    Money {                                                 // compact constructor: before fields are set
        if (cents < 0) throw new IllegalArgumentException("negative amount");
        currency = currency.strip().toUpperCase(Locale.ROOT);   // normalize
    }
    Money plus(Money o) { return new Money(cents + o.cents, currency); }
    public int compareTo(Money o) { return Long.compare(cents, o.cents); }
}
// generated: fields, canonical constructor, cents(), currency(), equals, hashCode, toString

Rules: final; can't extend a class (can implement interfaces); no instance fields beyond the components.

A sealed hierarchy

sealed interface Payment permits Card, BankTransfer, Wallet {}
record Card(String last4) implements Payment {}
record BankTransfer(String iban) implements Payment {}
non-sealed class Wallet implements Payment {}       // reopens this one branch

Each permitted subtype: final, sealed (with permits) or non-sealed. Same module (or package).

The traps

  • Array components: equals by identity.
  • Accessors are cents(), not getCents(): older getter-based libraries see nothing.
  • Mutable components: copy with List.copyOf in the compact constructor.
  • Missing final/sealed/non-sealed on a permitted class.
  • Exhaustive pattern switch over sealed types needs Java 21.

Staff-level points, one line each

  • equals/hashCode/toString are built at run time via invokedynamic + ObjectMethods; the hash formula is unspecified.
  • isRecord(), getRecordComponents() power framework support.
  • Primitive components compare with Double.compare & co: NaN equals NaN; 0.0 ≠ -0.0. Normalize in the compact constructor.
  • Compact or explicit canonical constructor, not both; other constructors delegate with this(...); accessors overridable with the same signature; component annotations propagate.
  • Deserialization calls the canonical constructor: validation runs on serialized data.
  • Records: great DTOs, config, keys, projections; not JPA entities (no no-arg constructor, final fields, no proxies).
  • Sealing is checked by the JVM (PermittedSubclasses, IncompatibleClassChangeError); isSealed(), getPermittedSubclasses().
  • Enum = closed set of instances; sealed = closed set of types with per-case data.
  • Data-oriented programming: records + sealed + exhaustive switch; replaces most Visitors.

Say it in an interview

  • "I'd model payment methods as a sealed interface of records, so the fee switch is checked for completeness."
  • "The record compares the array by identity, so I'd store a List instead."
  • "Records are fine for DTOs, but our JPA entities stay classes."

Practice

This skill comes with 12 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java 17: Records and Sealed Classes free

Money and Payment Fees

MediumProCoding problemJava

A checkout service in London models payment methods as a sealed interface of records (Card, BankTransfer, Wallet, given), and amounts as a Money record. Finish two things.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java 17: Records and Sealed Classes free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime