Records, sealed types, and pattern matching
Modelling "one of these shapes of data" with sealed interfaces and records, and taking it apart with an exhaustive pattern-matching switch.
After this lesson you can
- Write a record with a compact constructor that validates its input
- Close a hierarchy with a sealed interface and its permits clause
- Deconstruct records in a switch with record patterns and when guards
- Explain why a switch over a sealed type needs no default
A record is a class whose whole state is its components:
record Money(long cents, String currency) {} gets a canonical
constructor, accessors cents() and currency(), and equals,
hashCode and toString over both components. Its fields are
final, it cannot extend another class (it implicitly extends
java.lang.Record), and it can implement interfaces. A compact
constructor — no parameter list — runs before the fields are
assigned and is where validation and normalisation go.
import java.util.*; public class Solution { record Money(long cents, String currency) { Money { if (cents < 0) throw new IllegalArgumentException("negative amount: " + cents); currency = currency.toUpperCase(Locale.ROOT); // normalise before assignment } } public static List<String> describe() { Money a = new Money(250, "eur"); Money b = new Money(250, "EUR"); List<String> out = new ArrayList<>(); out.add("toString: " + a); out.add("equals: " + a.equals(b) + " sameHash: " + (a.hashCode() == b.hashCode())); out.add("accessor: " + a.cents()); try { new Money(-1, "usd"); } catch (IllegalArgumentException e) { out.add("rejected: " + e.getMessage()); } return out; }}Sealed interfaces
A sealed interface lists exactly which classes may implement it:
sealed interface Notification permits Email, Sms, Push {}
record Email(String to, String subject) implements Notification {}
record Sms(String number, String text) implements Notification {}
record Push(String device, int badge) implements Notification {}
Nobody else can add a fourth kind. (The permitted subclasses must be
final, sealed or non-sealed; records are implicitly final.)
That closed set is what makes the next part work: the compiler knows
every possible case.
Pattern matching in switch
Java 21 lets a switch match on the type of a value and take a
record apart in the same step, with record patterns. A when clause
adds a condition to a case.
import java.util.*; public class Solution { sealed interface Notification permits Email, Sms, Push {} record Email(String to, String subject) implements Notification {} record Sms(String number, String text) implements Notification {} record Push(String device, int badge) implements Notification {} static String route(Notification n) { return switch (n) { case Email(String to, String subject) -> "mail " + to + ": " + subject; case Sms(String number, String text) when text.length() > 10 -> "sms " + number + " (split, " + text.length() + " chars)"; case Sms(String number, String text) -> "sms " + number + ": " + text; case Push(var device, var badge) -> "push " + device + " badge=" + badge; }; // no default: the compiler knows these are all the cases } public static List<String> describe() { List<Notification> inbox = List.of( new Email("ana@example.com", "Invoice"), new Sms("+995555", "hi"), new Sms("+995555", "your code is 4417"), new Push("phone-1", 3)); List<String> out = new ArrayList<>(); for (Notification n : inbox) out.add(route(n)); return out; }}Because Notification is sealed, a switch that covers Email, Sms
and Push is exhaustive with no default — and if a fourth record is
ever permitted, every such switch stops compiling until it handles it.
This is the data-oriented alternative to adding a virtual method to
each class: the operation lives in one place, and the compiler checks
that it handles every case.
Cases are tried top to bottom, so a guarded case must come before the unguarded case for the same type; the compiler rejects a case that an earlier one makes unreachable ("dominated").
instanceof gained the same power: if (n instanceof Sms(var number, var text)) tests the type and binds the components at once, with no
cast.
Common mistakes
- Switching on a value that may be
null. Unless the switch has acase null, it throwsNullPointerExceptionimmediately, whatever the other cases are. - Putting the unguarded
case Sms sabovecase Sms s when ...— a compile error, because the guarded case could never run. - Adding a
defaultto a switch over a sealed type "to be safe". It compiles, and silently turns off the check that would have flagged a newly added subtype. - Writing
this.currency = ...inside a compact constructor. That is a compile error: the fields are assigned automatically at the end, so normalise by reassigning the parameter (), as the first example does.
import java.util.*; public class Solution { static String plain(Object o) { return switch (o) { case String s -> "string " + s; default -> "something else"; }; } static String withNull(Object o) { return switch (o) { case null -> "nothing"; case String s -> "string " + s; default -> "something else"; }; } public static List<String> describe() { List<String> out = new ArrayList<>(); try { out.add(plain(null)); } catch (NullPointerException e) { out.add("plain(null): NullPointerException"); } out.add("withNull(null): " + withNull(null)); return out; }}What interviewers ask
- What does a record generate, and what can't it do? Constructor,
accessors,
equals/hashCode/toString; it cannot extend a class or declare extra instance fields. - Why seal an interface? To make the set of implementations closed and known, so switches over it are checked for exhaustiveness and the domain model says exactly what can exist.
- What is a record pattern? A pattern like
Point(int x, int y)that matches a record type and binds its components, and can nest.
Exercise · Pro
Replay account events with a guarded pattern switch
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade