Sealed types give Java a closed list of subtypes, and pattern matching lets a switch test and take apart each one. Together they turn a forgotten case into a compile error instead of a silent bug.
A sealed type lists every class that’s allowed to extend or implement it. Pattern matching lets a switch check a value’s type and pull its fields out in the same step. Put the two together and the compiler knows every case your code has to handle, so when someone adds a new one, the build points at each place that forgot it.
This post starts with the bug sealed types prevent. Then it covers sealed and permits, type patterns, record patterns, guards with when, exhaustiveness, dominance, null and the unnamed pattern _. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.
The problem: a type check that forgets a case
A chain of instanceof checks with an else at the end compiles happily when a new subtype appears, and the new subtype quietly takes the else. Here’s a small payment system written the way Java code looked for years:
interface Payment {}
class Card implements Payment {
final int amount;
Card(int amount) {
this.amount = amount;
}
}
class BankTransfer implements Payment {
final int amount;
BankTransfer(int amount) {
this.amount = amount;
}
}
class Crypto implements Payment {
final int amount;
Crypto(int amount) {
this.amount = amount;
}
}
int fee(Payment p) {
if (p instanceof Card) {
Card c = (Card) p;
return c.amount * 2 / 100;
} else if (p instanceof BankTransfer) {
return 1;
} else {
return 0;
}
}
void main() {
IO.println("card fee: " + fee(new Card(250)));
IO.println("bank fee: " + fee(new BankTransfer(250)));
IO.println("crypto fee: " + fee(new Crypto(250)));
}
It prints:
card fee: 5
bank fee: 1
crypto fee: 0
Crypto was added after fee was written. Nobody updated fee, so every crypto payment is now free. Nothing crashed, and nothing warned you.
The compiler can’t help here, because Payment is open. Any class, anywhere, can implement it, so the compiler has no list to check your if chain against. The else is a guess about types that don’t exist yet, and the guess was wrong.
Notice the cast too. p instanceof Card checks the type, then (Card) p says it again. Both problems have a fix in modern Java.
Sealed interfaces: a closed list of subtypes
A sealed interface names the only types allowed to implement it, in a permits clause. Sealed classes and interfaces became a final feature in Java 17.
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
void main() {
Payment p = new Card(250, "EUR");
IO.println(p);
IO.println(Payment.class.isSealed());
}
It prints:
Card[amount=250, currency=EUR]
true
Card and BankTransfer are records, which are the natural leaf types for a sealed hierarchy: each one is a small, immutable bag of named values. The part on records covers them properly.
The list is enforced. Try to add a third payment type without adding it to permits:
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
record Crypto(int amount, String coin) implements Payment {}
void main() {
IO.println(new Crypto(250, "BTC"));
}
The build fails with:
Main.java:7: error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause)
The message says “sealed class” even though Payment is an interface, and “extend” even though Crypto implements it. javac uses the same wording for both. What matters is the second half: Crypto isn’t in the list.
Every permitted subtype must say what comes next
Each type in permits has to be marked final, sealed or non-sealed, so the hierarchy stays closed all the way down. A plain class isn’t allowed:
sealed interface Payment permits Card, BankTransfer, GiftCard {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
class GiftCard implements Payment {
int balance = 50;
}
void main() {
IO.println(new GiftCard().balance);
}
The build fails with:
Main.java:7: error: sealed, non-sealed or final modifiers expected
The three choices mean:
final: nothing can extend it. Records are implicitly final, which is whyCardandBankTransferneeded no modifier.sealed: it has its ownpermitslist, one level down.non-sealed: anyone can extend it. You open one branch on purpose, and you give up the closed list for that branch.
Here’s non-sealed letting a class that isn’t in any list join through GiftCard:
sealed interface Payment permits Card, BankTransfer, GiftCard {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
non-sealed class GiftCard implements Payment {
int balance() {
return 50;
}
}
class StoreCredit extends GiftCard {
@Override
int balance() {
return 20;
}
}
void main() {
Payment p = new StoreCredit();
IO.println(p instanceof GiftCard);
IO.println(((GiftCard) p).balance());
}
It prints:
true
20
StoreCredit isn’t named anywhere in Payment, yet it’s a Payment, because it’s a GiftCard. The compiler still knows the top level has exactly three branches. It just can’t list what’s under GiftCard.
When you can leave out permits
If the permitted subtypes are declared in the same file as the sealed type, you can drop permits, and the compiler collects them for you:
sealed interface Payment {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
void main() {
for (var type : Payment.class.getPermittedSubclasses()) {
IO.println(type.getSimpleName());
}
}
It prints:
Card
BankTransfer
Every program in this post is one file, so permits is optional in all of them. The rest of the post keeps it anyway, so you can see the list. In a real project the subtypes usually live in their own files, and then permits is required.
Type patterns: check the type and name it in one step
A type pattern, such as p instanceof Card c, checks the type and, when it matches, gives you a variable of that type. There’s no cast to write. Pattern matching for instanceof became final in Java 16.
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
String describe(Payment p) {
if (p instanceof Card c && c.amount() >= 100) {
return "large card payment in " + c.currency();
}
if (!(p instanceof Card c)) {
return "not a card";
}
return "small card payment of " + c.amount();
}
void main() {
IO.println(describe(new Card(250, "EUR")));
IO.println(describe(new Card(40, "EUR")));
IO.println(describe(new BankTransfer(900, "DE89 3704")));
}
It prints:
large card payment in EUR
small card payment of 40
not a card
c is called a binding variable. It exists only where the match is certain:
- After
&&: the right side runs only when the left side matched, soc.amount()is safe there. - After a negated check that returns:
if (!(p instanceof Card c)) return ...means every line below it runs only for a card, socstays in scope for the rest of the method.
Swap && for || and the build fails with cannot find symbol, because the right side of || runs exactly when the match failed.
switch over a sealed type needs no default
A switch can use type patterns as its cases, and over a sealed type it can list every permitted subtype with no default at all. Patterns in switch became final in Java 21.
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
int fee(Payment p) {
return switch (p) {
case Card c -> c.amount() * 2 / 100;
case BankTransfer t -> 1;
};
}
void main() {
IO.println(fee(new Card(250, "EUR")));
IO.println(fee(new BankTransfer(250, "DE89 3704")));
}
It prints:
5
1
Compare this with the if chain at the start. There’s no cast and no else. The compiler accepted the switch without a default because it read permits, saw two types, and found a case for each. A switch that covers every possible value is called exhaustive.
Adding a subtype breaks the build, on purpose
Exhaustiveness pays off the day someone adds a new permitted type. Here is Crypto added to Payment, with fee left untouched:
sealed interface Payment permits Card, BankTransfer, Crypto {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
record Crypto(int amount, String coin) implements Payment {}
int fee(Payment p) {
return switch (p) {
case Card c -> c.amount() * 2 / 100;
case BankTransfer t -> 1;
};
}
void main() {
IO.println(fee(new Crypto(250, "BTC")));
}
The build fails with:
Main.java:10: error: the switch expression does not cover all possible input values
return switch (p) {
^
This is the same mistake as the if chain at the start. That one shipped a free crypto payment. This one doesn’t build. In a large codebase, every switch over Payment fails at once, and the error list is your to-do list.
A switch statement gets the same check once it uses patterns. javac reports the switch statement does not cover all possible input values. An old-style switch statement over an enum, with no patterns, is still allowed to skip values.
A default branch throws the safety away
Add default to the same switch and it compiles again, with the old bug back:
sealed interface Payment permits Card, BankTransfer, Crypto {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
record Crypto(int amount, String coin) implements Payment {}
int fee(Payment p) {
return switch (p) {
case Card c -> c.amount() * 2 / 100;
case BankTransfer t -> 1;
default -> 0;
};
}
void main() {
IO.println("crypto fee: " + fee(new Crypto(250, "BTC")));
}
It prints:
crypto fee: 0
default matches everything no other case matched, and that includes types that didn’t exist when you wrote it. The compiler has nothing to complain about, so it doesn’t. Over a sealed type, leave default out and list the cases. Then the compiler does the remembering for you.
Explain it like I’m ten
Picture a shape-sorter toy. The box says it comes with exactly three shapes: a star, a square and a circle. That list printed on the box is the sealed type.
Your lid is the switch. Because you know there are only three shapes, you can check the lid before you play: a star hole, a square hole, a circle hole. Every shape has a hole, so nothing can get stuck.
Next year the company adds a triangle and prints a new list on the box. The moment you check your old lid against the new list, you see there’s no triangle hole. You find out before a triangle ever shows up.
A default branch is a big hole cut in the middle of the lid. The triangle falls straight through it, and you never notice that you forgot it.
The precise version
A sealed type’s permits list is part of its compiled class file. When javac compiles a switch over a sealed type, it checks that the cases cover every permitted subtype, following sealed subtypes down to their own lists. If they don’t, and there’s no default, the switch doesn’t compile.
Two details make the check stricter than it looks. A case with a guard (when, covered below) doesn’t count towards coverage, because the compiler can’t know the guard will be true. And the check needs a closed list: the same switch over a plain, unsealed interface fails with the same error until you add default.
Where the analogy breaks: the check happens when you compile, not when a shape arrives. If Payment gains Crypto and is recompiled, but the class containing your switch isn’t, nothing re-checks your lid. Java still doesn’t let the new type slip through. The compiler adds a hidden branch to every exhaustive switch, and a Crypto arriving there throws java.lang.MatchException. That’s what we got when we tried it with two separately compiled files. Recompile everything and you get the compile error instead.
Record patterns: take the record apart in the case
A record pattern, such as Card(int amount, String currency), matches the type and pulls out the record’s components into variables, in one case. Record patterns became final in Java 21.
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
String describe(Payment p) {
return switch (p) {
case Card(int amount, String currency) -> amount + " " + currency + " by card";
case BankTransfer(var amount, var iban) -> amount + " from account " + iban;
};
}
void main() {
IO.println(describe(new Card(250, "EUR")));
IO.println(describe(new BankTransfer(900, "DE89 3704")));
Object thing = new Card(40, "USD");
if (thing instanceof Card(int amount, String currency)) {
IO.println("unpacked " + amount + " and " + currency);
}
}
It prints:
250 EUR by card
900 from account DE89 3704
unpacked 40 and USD
The components come out in the order the record declares them. The names in the pattern are yours to choose. They don’t have to match the record’s names, although using the same ones keeps the code readable. var lets the compiler fill in each type, as in the BankTransfer case.
Record patterns work in instanceof too, as the last lines show.
Guards with when
A guard adds a condition to a case: case Card c when c.amount() < 100 matches only cards under 100. If the pattern matches but the guard is false, the switch moves on to the next case.
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
String fee(Payment p) {
return switch (p) {
case BankTransfer(int amount, String iban) -> "flat fee 1 from " + iban;
case Card(int amount, String currency) when amount < 100 -> "no fee";
case Card(int amount, String currency) -> "fee " + amount * 2 / 100 + " " + currency;
};
}
void main() {
IO.println(fee(new Card(250, "EUR")));
IO.println(fee(new Card(40, "EUR")));
IO.println(fee(new BankTransfer(900, "DE89 3704")));
}
It prints:
fee 5 EUR
no fee
flat fee 1 from DE89 3704
Small card payments are free, larger ones pay 2%, and a transfer pays a flat 1. The guard can use the variables its own pattern just bound, as amount < 100 does.
The third case has no guard, and that’s what keeps the switch exhaustive. Delete it and the build fails with the switch expression does not cover all possible input values, even though a card case is still there. A guarded case never counts towards coverage.
Watching a switch pick a case
The cases in a pattern switch are tried from top to bottom, and the animation follows new Card(250, "EUR") through the switch above:
new Card(250, “EUR”) tested against three cases in order. The BankTransfer pattern fails on the type. The first Card pattern matches, but its guard, amount < 100, is false, so it’s skipped. The unguarded Card pattern matches, binds amount and currency, and its expression becomes the result.
Here are those steps in words, in case the animation doesn’t play for you:
- The switch receives
new Card(250, "EUR")and starts at the first case. case BankTransfer(int amount, String iban)checks the type first. The value is aCard, so the pattern doesn’t match and nothing is bound.case Card(int amount, String currency) when amount < 100matches the type and bindsamountto 250. Then the guard runs.250 < 100is false, so this case is skipped.case Card(int amount, String currency)matches, and it has no guard, so this case is chosen.- The pattern binds
amountto 250 andcurrencyto"EUR". - The expression after
->runs and produces"fee 5 EUR", which becomes the value of the whole switch. No later case is tried.
Nested patterns, and _ for the parts you don’t need
Record patterns nest, so one case can reach inside a record that holds other records. The unnamed pattern _ stands in for any component you don’t need. It became final in Java 22.
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
record Customer(String name, boolean vip) {}
record Order(Customer customer, Payment payment) {}
int fee(Order order) {
return switch (order) {
case Order(Customer(_, boolean vip), _) when vip -> 0;
case Order(_, Card(int amount, String currency)) when currency.equals("EUR") ->
amount * 2 / 100;
case Order(_, Card(int amount, _)) -> amount * 3 / 100;
case Order(_, BankTransfer _) -> 1;
};
}
void main() {
var ana = new Customer("Ana", true);
var ben = new Customer("Ben", false);
IO.println(fee(new Order(ana, new Card(250, "EUR"))));
IO.println(fee(new Order(ben, new Card(250, "EUR"))));
IO.println(fee(new Order(ben, new Card(250, "USD"))));
IO.println(fee(new Order(ben, new BankTransfer(250, "DE89 3704"))));
}
It prints:
0
5
7
1
Read the cases from the top:
- VIP customers pay nothing. The pattern reaches into the customer, binds
vip, and ignores the name and the whole payment with_. - Euro cards pay 2%. The pattern skips the customer and reaches into the payment.
- Other cards pay 3%.
Card(int amount, _)needs the amount but not the currency. 3% of 250 is 7.5, and integer division gives 7. - Transfers pay 1.
BankTransfer _matches the type and binds nothing.
_ says “there’s something here, and I’m not using it”, which tells the reader as much as it tells the compiler. You can’t read _ afterwards, and you can use it more than once in the same pattern.
A related feature is still in preview. Primitive types in patterns, such as case int i when i < 10, are a preview feature in Java 25, and javac rejects them unless you pass --enable-preview. This series doesn’t use them.
Order matters: a broad case can hide a narrow one
A case that can never be reached is a compile error, and the usual way to write one is to put a broad case above a narrower one. Here the guarded card case comes after the plain one:
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
String fee(Payment p) {
return switch (p) {
case Card c -> "fee " + c.amount() * 2 / 100;
case Card c when c.amount() < 100 -> "no fee";
case BankTransfer t -> "flat fee 1";
};
}
void main() {
IO.println(fee(new Card(40, "EUR")));
}
The build fails with:
Main.java:10: error: this case label is dominated by a preceding case label
case Card c when c.amount() < 100 -> "no fee";
^
case Card c matches every card, so a card under 100 would never reach the line below. The compiler calls that dominance: the first case dominates the second. If this compiled, small payments would quietly be charged a fee.
The fix is to order cases from narrow to broad, as the guards example did. The same error appears if you put case Payment q above case BankTransfer t, because every transfer is a payment.
null and switch
A pattern switch throws NullPointerException when the value is null, unless one of its cases is case null. Exhaustiveness doesn’t cover null, so a switch that covers every type still fails at run time:
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
int fee(Payment p) {
return switch (p) {
case Card c -> c.amount() * 2 / 100;
case BankTransfer t -> 1;
};
}
void main() {
IO.println(fee(new Card(250, "EUR")));
IO.println(fee(null));
}
It prints, then stops:
5
Exception in thread "main" java.lang.NullPointerException
The exception has no message. Java’s helpful NullPointerException messages normally name the variable that was null, but this one doesn’t. The first stack frame is java.util.Objects.requireNonNull, which the compiler inserted before the switch. If you see a bare NPE on a line with switch, check the value you switched on.
When null is a value you expect, say so with case null:
sealed interface Payment permits Card, BankTransfer {}
record Card(int amount, String currency) implements Payment {}
record BankTransfer(int amount, String iban) implements Payment {}
String fee(Payment p) {
return switch (p) {
case null -> "no payment yet";
case Card c -> "fee " + c.amount() * 2 / 100;
case BankTransfer t -> "flat fee 1";
};
}
void main() {
IO.println(fee(new Card(250, "EUR")));
IO.println(fee(null));
Payment missing = null;
IO.println(missing instanceof Card c ? "card " + c.amount() : "not a card");
}
It prints:
fee 5
no payment yet
not a card
case null is just another case, and adding it doesn’t disturb exhaustiveness. instanceof never needed one: null instanceof Card is simply false, so the last line took the “not a card” branch. For a switch over a type that isn’t sealed, case null, default -> handles both leftovers in one line.
What to remember
- A
sealedinterface or class lists its permitted subtypes. Each one must befinal,sealedornon-sealed, and records are already final. x instanceof Card cchecks the type and bindscwith no cast. Record patterns likeCard(int amount, String currency)also pull out the components.- A
switchover a sealed type that covers every subtype needs nodefault. Add a new subtype and each switch that misses it stops compiling. - A
defaultbranch hides new subtypes, which brings back the silent bug. A guarded case doesn’t count towards covering a type. - Cases are tried from top to bottom. Put narrow cases above broad ones, or javac reports that a case is dominated.
- A pattern
switchthrowsNullPointerExceptiononnullunless it hascase null. - Use
_for components you don’t need.
A sealed type gives the compiler the full list, so it can tell you which case you forgot.