A sealed type declares exactly which types may extend it. The compiler enforces the list, and —
this is the real payoff — it can then prove that a switch over that type has covered
every case.
The problem
An ordinary interface is open: anyone, anywhere, can implement it. That is usually the point. But when you are modelling a closed set of alternatives — a payment is a card, a bank transfer or store credit, and there is no fourth kind — openness works against you:
interface Payment { }
record Card(String last4) implements Payment { }
record BankTransfer(String iban) implements Payment { }
class Processor {
String describe(Payment payment) {
if (payment instanceof Card c) return "card ending " + c.last4();
if (payment instanceof BankTransfer b) return "transfer from " + b.iban();
return "unknown"; // <- this line exists only because the compiler cannot help
}
}
That fallback is dead weight. Worse, when someone adds StoreCredit next year, this
method silently returns "unknown" instead of failing to compile.
Sealing it
sealed interface Payment permits Card, BankTransfer, StoreCredit { }
record Card(String last4, double amount) implements Payment { }
record BankTransfer(String iban, double amount) implements Payment { }
record StoreCredit(double amount) implements Payment { }
permits lists every allowed subtype. Anything else that tries to implement
Payment will not compile — and the permitted types must be in the same package (or the
same module).
The permits clause can be omitted when all the subtypes are in the same file, which is
common and reads well:
sealed interface Shape { // no permits needed — all subtypes are right here
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
record Rectangle(double w, double h) implements Shape { }
}
Every subtype must choose
A type permitted by a sealed parent has to say what happens next. There are three options and no default:
sealed interface Vehicle permits Car, Truck, Trailer { }
final class Car implements Vehicle { } // final: the line stops here
sealed class Truck implements Vehicle permits PickupTruck { } // sealed: continues, but controlled
final class PickupTruck extends Truck { }
non-sealed class Trailer implements Vehicle { } // non-sealed: deliberately reopened
class CargoTrailer extends Trailer { } // ...so anyone may extend this one
non-sealed is an explicit escape hatch — it says "the hierarchy is closed above here
and open below". It is the only hyphenated keyword in Java, which is a reasonable mnemonic for how
rarely you should need it.
The payoff: exhaustive switch
Because the compiler knows the complete list, a switch over a sealed type needs no
default:
sealed interface Payment permits Card, BankTransfer, StoreCredit { }
record Card(String last4, double amount) implements Payment { }
record BankTransfer(String iban, double amount) implements Payment { }
record StoreCredit(double amount) implements Payment { }
class Processor {
String describe(Payment payment) {
return switch (payment) { // no default — all three are covered
case Card c -> "card ending " + c.last4();
case BankTransfer b -> "transfer from " + b.iban();
case StoreCredit s -> "store credit of " + s.amount();
};
}
}
This is the whole reason sealed types exist. Add a fourth payment kind to the
permits list and this method stops compiling — along with every other switch that
handles payments. The compiler hands you the list of places to update instead of leaving you to find
them.
Resist adding a default branch out of habit. A default throws that
guarantee away: the switch compiles forever, and the new case silently falls into it.
Record patterns
Since Java 21 a case can destructure a record, pulling its components out in the
same step:
sealed interface Shape { }
record Circle(double radius) implements Shape { }
record Rectangle(double w, double h) implements Shape { }
class Geometry {
double area(Shape shape) {
return switch (shape) {
case Circle(double r) -> Math.PI * r * r; // r bound directly
case Rectangle(double w, double h) -> w * h;
};
}
String classify(Shape shape) {
return switch (shape) {
case Rectangle(double w, double h) when w == h -> "square";
case Rectangle r -> "rectangle";
case Circle c when c.radius() > 100 -> "big circle";
case Circle c -> "circle";
};
}
}
Note the ordering rule in classify: a guarded case must come before the unguarded case
for the same type, or the unguarded one would match everything first — and the compiler tells you
so.
When to use them
Sealed types fit whenever you have a fixed set of alternatives that you want to handle exhaustively:
- Results —
sealed interface Result permits Success, Failure, so no caller can forget the failure case. - State machines — the states of an order, a connection, a job.
- Expression trees — parsers, rule engines, anything you walk with a switch.
- Domain alternatives — payment methods, notification channels, account types.
They pair naturally with records: the sealed interface names the alternatives, and each record carries that alternative's data. Together they give you in plain Java what other languages call an algebraic data type.
Do not seal a plugin point. If the whole purpose of an interface is for other people — other teams, other modules, library users — to implement it, sealing is exactly wrong. Seal closed sets; leave extension points open.
Next
Sealed types are what make an exhaustive switch possible. Switch Expressions is next.