Sealing closes the set of subtypes, which lets the compiler prove a pattern
match covers every case — turning a missed case from a run-time surprise into a
compilation failure.
public sealed interface Payment permits Card, Transfer, Voucher {}
public record Card(String last4, Money amount) implements Payment {}
public record Transfer(String iban, Money amount) implements Payment {}
public record Voucher(String code, Money amount) implements Payment {}
static String describe(Payment payment) {
return switch (payment) {
case Card c -> "card ending " + c.last4();
case Transfer t -> "transfer to " + t.iban();
case Voucher v -> "voucher " + v.code();
};
}
The value is the absence of the default branch. Without sealing you must write
one, and it can only throw — so a new subtype compiles cleanly, ships, and fails
at run time on the first payment of that kind. With sealing, adding Voucher
to the permitted list breaks every incomplete switch in the codebase at compile
time, which is exactly where you want to find them.
Record patterns take it further, destructuring in the same expression:
return switch (payment) {
case Card(String last4, Money amount) when amount.isOver(500)
-> "large card payment ending " + last4;
case Card(String last4, var ignored) -> "card ending " + last4;
case Transfer(String iban, var ignored) -> "transfer to " + iban;
case Voucher v -> "voucher " + v.code();
};
Two details worth carrying. permits can be omitted when the subtypes are in
the same file, which is the compact form for a small hierarchy. And every
permitted subtype must itself be final, sealed or explicitly non-sealed,
so the compiler always knows where the hierarchy stops — a non-sealed subtype
reopens that branch deliberately, and costs you exhaustiveness below it.