09/26/2026
Java sealed interfaces and record patterns in a switch
A switch over a sealed interface is the modern replacement for a chain of instanceof checks, and the reason to use it is not brevity. When the switch covers every permitted type, the compiler knows it, so adding a new type later turns every switch that forgot about it into a compile error instead of a silent fall-through. Pattern matching for switch and record patterns are final in Java 21; the _ placeholder used below needs Java 22.
import java.math.BigDecimal;
import java.util.List;
public class Payments {
sealed interface Payment permits Card, BankTransfer, Voucher {}
record Card(String last4, BigDecimal amount) implements Payment {}
record BankTransfer(String iban, BigDecimal amount, boolean instant) implements Payment {}
record Voucher(String code) implements Payment {}
static BigDecimal fee(Payment p) {
return switch (p) {
case Card(var last4, var amount) -> amount.multiply(new BigDecimal("0.029"));
case BankTransfer(var iban, var amount, var instant) when instant -> new BigDecimal("1.50");
case BankTransfer t -> BigDecimal.ZERO;
case Voucher v -> BigDecimal.ZERO;
};
}
static String describe(Payment p) {
return switch (p) {
case Card(String last4, _) -> "card ending " + last4;
case BankTransfer(_, _, boolean instant) -> instant ? "instant transfer" : "transfer";
case Voucher(String code) -> "voucher " + code;
};
}
public static void main(String[] args) {
List<Payment> payments = List.of(
new Card("4242", new BigDecimal("100.00")),
new BankTransfer("GB00TEST", new BigDecimal("250.00"), true),
new BankTransfer("GB00TEST", new BigDecimal("250.00"), false),
new Voucher("WELCOME10"));
for (Payment p : payments) {
System.out.println(describe(p) + " -> fee " + fee(p));
}
}
}
Compiled and run on OpenJDK 25.0.2:
card ending 4242 -> fee 2.90000
instant transfer -> fee 1.50
transfer -> fee 0
voucher WELCOME10 -> fee 0
There is no default branch, and that is on purpose.
What each piece does
sealed ... permits fixes the list of implementations. JEP 409, final in Java 17, requires the permitted classes to live in the same module, or the same package if you have no modules, and each must be final, sealed or non-sealed. Records are implicitly final, which is why they fit so neatly. They also turn the value class behind a groupingBy stream into one line.
Record patterns such as Card(var last4, var amount) test the type and pull the components out in one step. They were finalized in Java 21 by JEP 440, and they nest: given a record Order(Payment payment, BigDecimal total), the pattern Order(Card(var last4, _), var total) matches only orders paid by card.
when guards add a condition to a case. The instant transfer gets a flat fee; every other transfer is handled by the next case.
_ marks a component you do not need. Unnamed patterns came in Java 22 with JEP 456. Compile with --release 21 on a newer JDK and the compiler says so plainly:
error: unnamed variables are not supported in -source 21
(use -source 22 or higher to enable unnamed variables)
A JDK 21 compiler reports instead that unnamed variables are a preview feature and disabled by default: Java 21 had them only as a preview, in JEP 443. On Java 21, replace each _ with a named variable you ignore.
The payoff: adding a type
Add a fourth payment type to the permits list and change nothing else:
sealed interface Payment permits Card, BankTransfer, Voucher, Crypto {}
record Crypto(String wallet, BigDecimal amount) implements Payment {}
javac now refuses both switches:
Payments.java:14: error: the switch expression does not cover all possible input values
return switch (p) {
^
Payments.java:23: error: the switch expression does not cover all possible input values
2 errors
Every place that decides something per payment type is listed for you. With an instanceof chain, the new type would have reached the final else and returned whatever that returned.
The trap: default switches that off
Add default -> BigDecimal.ZERO; to the same switch and that switch accepts the new Crypto type without a word, and gives it a zero fee. JEP 441, which finalized pattern matching for switch in Java 21, lets the compiler use the permits list to prove a switch exhaustive precisely so that you can leave default out. Writing it anyway throws that check away.
The rule that follows: over a sealed type, list the cases and omit default. If some types genuinely share behavior, name them together in one case, case Voucher _, Crypto _ -> BigDecimal.ZERO;, so the choice is still explicit. That needs Java 22 or later, and the patterns must be unnamed: case Voucher v, Crypto c fails with "illegal fall-through from a pattern".
The same thing happens if the interface is not sealed. Remove sealed ... permits and the original switch, with no new type at all, fails with the same "does not cover all possible input values" error, because the compiler can no longer know the list is complete.
Case order matters
Cases are tried top to bottom, and the compiler rejects one that can never be reached. Put the unguarded case BankTransfer t above the guarded one and compilation stops:
Payments.java:16: error: this case label is dominated by a preceding case label
case BankTransfer(var iban, var amount, var instant) when instant -> new BigDecimal("1.50");
^
Specific and guarded cases go first; the catch-all for that type goes last.
The runtime backstop
Exhaustiveness is checked when your code compiles. If the sealed hierarchy lives in a library that gains a new type, and your code is not recompiled against it, the switch meets a value it was never told about. To see it, a Shape interface permitting Circle and Square was compiled with a switch over it, then the interface was changed to permit a new Triangle and recompiled, while the class holding the switch was not. Handing the old switch a Triangle:
Exception in thread "main" java.lang.MatchException
at Main.name(Main.java:3)
A loud MatchException is better than a wrong answer, but the real fix is to rebuild dependants whenever a sealed hierarchy they switch over changes.
Two smaller points
null is not a case unless you write one. Per JEP 441, a switch with no case null throws NullPointerException when handed null, as switches always have. Add case null -> if null is a value you expect.
Pattern variables you never read still cost a name. In fee(), last4, iban and the transfer's amount are bound and unused, as are t and v. On Java 22 or later, _ in their place tells the reader that is deliberate.
Where this fits
- A closed set of alternatives: payment methods, parser tokens, result types, states
- A sealed interface with records, switched on with no default
- Third parties are meant to add implementations
- An ordinary interface with polymorphic methods; permits is a deliberate wall
- Several permitted types share behavior
- Name them together in one case (Java 22 or later)
- The sealed hierarchy lives in a library
- Recompile dependants when it changes, or expect MatchException
Questions this raises
Do I need a default case when switching over a sealed interface?
No, and it is better left out. When the cases cover every permitted type, javac proves the switch exhaustive, so adding a new type makes every switch that misses it fail to compile. A default branch quietly accepts the new type instead.
Which Java version do I need for record patterns in a switch?
Java 21, where pattern matching for switch (JEP 441) and record patterns (JEP 440) became final. Sealed classes date from Java 17, and the unnamed _ pattern from Java 22.
What does "the switch expression does not cover all possible input values" mean?
javac cannot prove the cases handle every possible value. Either a permitted type has no case, or the interface is not sealed, so the compiler cannot know the list is complete.
Why does an exhaustive switch throw MatchException at runtime?
The sealed hierarchy gained a type after the class holding the switch was compiled, and that class was not recompiled. Rebuild everything that switches over a sealed type whenever its permits list changes.