What do sealed classes enable that abstract classes cannot?
An abstract class says 'anyone may extend me'. A sealed one names its subtypes, so the compiler knows the list is complete — and can prove a switch covers every case. That turns adding a subtype into a compile error at every place that has to handle it, which is the opposite of the silent default branch you get today.
The Answer
abstractcontrols how you extend.sealedcontrols who may — you list the permitted subtypes and the compiler enforces the list.- Because the list is complete and known at compile time, a
switchover a sealed type can be checked for exhaustiveness. Nodefaultbranch needed. - That is the real payoff: add a fourth subtype and every switch that does not handle it stops compiling. The compiler hands you the list of places to update.
- Compare with
default: throw new IllegalStateException(...)— the same mistake becomes a runtime failure, in production, on the one code path nobody tested. - Permitted subtypes must be
final,sealed, or explicitlynon-sealed. The hierarchy has to stay closed or deliberately reopened; there is no accidental middle. - Combined with records it gives you algebraic data types:
sealed interface Payment permits Card, Upi, Cashis "a payment is one of exactly these three", checkable.
Understand It
Exhaustiveness is the feature; everything else is syntax
Here is the whole idea in one method. Four cases, no default, and a guard on one of them:
List<Payment> payments = List.of(
new Card("visa", 4242),
new Card("amex", 1005),
new Upi("ravi@okhdfc"),
new Cash(new BigDecimal("250.00")));
for (Payment payment : payments) {
System.out.println(describe(payment));
}visa ending 4242
amex ending 1005
UPI ravi@okhdfc
cash 250.00describe is in this entry's harness, and it reads:
static String describe(Payment payment) {
return switch (payment) {
case Card(String network, int last) when network.equals("amex") -> "amex ending " + last;
case Card(String network, int last) -> network + " ending " + last;
case Upi(String handle) -> "UPI " + handle;
case Cash c -> "cash " + c.amount();
};
}
Now delete the Cash case and try to compile:
error: the switch expression does not cover all possible input values
That error is the entire value proposition. Without sealed, the compiler cannot know whether the list is complete, so it forces you to write a default. And a default is exactly the wrong shape for this: it is a silent catch-all that turns "you forgot a case" from a compile error into an exception at 3am, on the branch that only the new payment type reaches.
Add Netbanking to the permits clause and the compiler immediately lists every switch that now has a hole. You do not have to find them, and you cannot miss one.
What the compiler is actually checking
System.out.println("Payment is sealed : " + Payment.class.isSealed());
for (Class<?> permitted : Payment.class.getPermittedSubclasses()) {
System.out.println(" permits : " + permitted.getSimpleName()
+ (Modifier.isFinal(permitted.getModifiers()) ? " (final)" : ""));
}Payment is sealed : true
permits : Card (final)
permits : Upi (final)
permits : Cash (final)The permits list is not a source-only convention — it is written into the class file as a PermittedSubclasses attribute, and the JVM enforces it at class load, not just javac. Write a class that implements a sealed interface it is not permitted to:
error: class is not allowed to extend sealed class: Payment
(as it is not listed in its 'permits' clause)
This is the difference from the old trick of a package-private constructor. That closes a hierarchy only within a package, cannot express "these three, in three different packages", and is invisible to the compiler's exhaustiveness analysis. Sealing is enforced, expressible across packages within a module, and the compiler reasons about it.
Each permitted subtype must then declare its own stance:
| Modifier on the subtype | Meaning |
|---|---|
final | the branch ends here — what a record gives you for free |
sealed | it continues, but with its own named list |
non-sealed | it is reopened deliberately, and anyone may extend it |
There is no fourth option, and that is the point: leaving it unspecified is a compile error, so "we accidentally left the hierarchy open" cannot happen quietly.
Record patterns call your accessor — and this surprises everyone
case Card(String network, int last) looks like field access. It is not. A record pattern invokes the accessor methods, one per component, at match time.
Almost always that is invisible, because the generated accessor just returns the field. Write a custom one and it stops being invisible:
record Wrapper(Object payload) {
@Override
public Object payload() {
throw new IllegalStateException("lazy load failed");
}
}
Object value = new Wrapper("hello");
try {
String result = switch (value) {
case Wrapper(String text) -> "text " + text;
default -> "something else";
};
System.out.println(result);
} catch (Throwable t) {
System.out.println("threw : " + t.getClass().getName());
System.out.println("cause : " + t.getCause());
}threw : java.lang.MatchException
cause : java.lang.IllegalStateException: lazy load failedNote what you did not get: your IllegalStateException. Java 21+A record accessor that throws during pattern matching has its exception wrapped in a MatchException, so a catch (IllegalStateException e) around that switch would not fire.
Two practical consequences. First, keep record accessors trivial — no lazy loading, no validation, no logging. A record component is data, and a pattern match is entitled to read it cheaply and safely. Second, if you ever see MatchException in a log, the interesting information is in getCause(); the exception type itself only tells you where it happened.
null stops being a special case, but only if you ask
A traditional switch on a reference throws NullPointerException on a null selector, and there was never anything you could do about it inside the switch. A pattern switch keeps that default — and then lets you opt out with an explicit case null:
Payment none = null;
try {
System.out.println(describe(none));
} catch (NullPointerException e) {
System.out.println("no case null -> NullPointerException");
}
String handled = switch ((Payment) null) {
case null -> "explicitly handled";
case Card c -> "card";
case Upi u -> "upi";
case Cash c -> "cash";
};
System.out.println("with case null : " + handled);no case null -> NullPointerException
with case null : explicitly handledThe default was chosen for compatibility — every switch written before 21 assumes null throws — but the opt-in is the better habit at a boundary where null is a real value. Writing case null also documents that you thought about it, which a wrapping if (x == null) does not.
Why this is not just an enum
The obvious objection is that enums have been closed sets since Java 5, and switch over an enum has warned about missing constants for nearly as long. True, and the difference is data.
An enum constant is a singleton. Card carries a network and last four digits; the next Card carries different ones. You cannot express that with enum constants without inventing a parallel object to hold the fields, at which point the enum is just a tag you have to keep in sync by hand.
Sealed interface plus records gives you both halves at once: a closed set the compiler can check, and per-instance data. That combination is what functional languages call a sum type, and the reason it shows up in Java now is that switch finally became an expression that can deconstruct one.
Reference
The three shapes, and when each is right
// 1. Sealed interface + records. The default choice for a closed set of
// data-carrying alternatives: a result, a command, an event, a state.
public sealed interface Result<T> permits Ok, Err { }
public record Ok<T>(T value) implements Result<T> { }
public record Err<T>(String message) implements Result<T> { }
// 2. Sealed abstract class, when the cases share state or behaviour that
// would be duplicated across records.
public sealed abstract class Shape permits Circle, Rect {
protected final String id; // shared, and records cannot inherit it
protected Shape(String id) { this.id = id; }
public abstract double area();
}
public final class Circle extends Shape { /* ... */ }
// 3. non-sealed, when one branch genuinely must stay open — a framework
// extension point inside an otherwise closed hierarchy.
public sealed interface Node permits Leaf, Branch, Custom { }
public record Leaf(String text) implements Node { }
public record Branch(List<Node> kids) implements Node { }
public non-sealed interface Custom extends Node { } // deliberately reopened
Where the permitted subtypes are allowed to live
// Same file — permits can be omitted entirely; javac infers it.
public sealed interface Payment { }
record Card(String network, int lastFour) implements Payment { }
// Same package, different files — permits is required, and lists them.
public sealed interface Payment permits Card, Upi, Cash { }
// Different packages — legal ONLY inside a named module, because the module
// is what makes the set verifiable. In an unnamed module (a plain classpath
// app), permitted subtypes must share the package.
That last rule is the one that bites during migration: a sealed hierarchy split across packages compiles inside a module and fails on a plain classpath.
The case-label forms
One per line, because several of these cannot coexist in the same switch — case Card c covers every Card, so anything more specific below it is a compile error, and case null and case null, default are two ways to write the same thing.
| Form | Written as |
|---|---|
| type pattern | case Card c -> c.network(); |
| record pattern | case Card(String network, int last) -> network; |
var in a component | case Card(String network, var last) -> network; |
| nested record pattern | case Order(Card(String network, var last), var total) -> network; |
| guarded | case Card(String n, int l) when l == 0 -> "invalid"; |
| guarded on a type pattern | case Upi u when u.handle().endsWith("@okaxis") -> "axis"; |
| null, handled | case null -> "missing"; |
| null plus everything else | case null, default -> "missing or other"; |
Two ordering rules the compiler enforces for you:
- A guarded case goes above the unguarded case for the same type.
case Card c when …must precedecase Card c, or the second is unreachable. - A case that can never be reached is an error, not a warning. That is what makes reordering patterns a safe refactor — unlike an
if/else ifchain, you cannot silently shadow a branch.
The guard keyword is when, not if. Using if there is a syntax error, and it is the single most common typo when people start writing these.
Migrating an existing hierarchy
// Before: open, and every switch needs a default it does not want.
public interface Payment { }
// Step 1 — seal it, listing what already exists. Nothing else changes.
public sealed interface Payment permits Card, Upi, Cash { }
// Step 2 — make each permitted type state its stance. Records are final
// already; classes need final, sealed or non-sealed.
public final class Cash implements Payment { }
// Step 3 — now delete the defaults. Each deletion either compiles, proving
// the switch was already exhaustive, or names a case you were silently
// swallowing. Both outcomes are worth having.
Scenarios
A payment type is added and one service quietly mis-bills. The codebase switches on payment kind in eleven places, each ending with default -> throw new IllegalStateException(). Nine were updated for the new type; two were not, and one of those two computes a fee — so it throws in production for exactly the customers using the new method. Sealing the interface converts this from a release-night incident into a compile error listing both files. This is the case worth leading with in an interview, because it is about a failure mode rather than about syntax.
Someone wants to seal the domain model everywhere. Push back. Sealing is right where the set really is closed and the consumers really do need to handle every case: results, commands, events, parse trees, state machines. It is wrong on an extension point — a PaymentProvider that third parties implement, a plugin interface, anything where "we will add more of these" is the design. The test is whether adding a subtype should force every consumer to be revisited. If the answer is no, an open interface is not a weaker choice, it is the correct one.
Your sealed hierarchy will not compile after a package refactor. Someone split Card, Upi and Cash into per-provider packages, and the build fails with a message about the permitted subtypes. Permitted subtypes may live in different packages only within a named module; on a plain classpath they must share a package. The options are to move them back, to modularise, or to accept that the hierarchy is not sealable in its current layout. Knowing this in advance is worth more than knowing the syntax, because it is a structural constraint on where the types are allowed to live.
A MatchException appears in the logs and nobody can reproduce it. The team is looking for a bug in the switch. The switch is fine — a record accessor threw during matching and the pattern machinery wrapped it, so the real failure is in getCause(). Two things come out of this: fix the logging to unwrap causes, and then fix the record, because a component accessor doing work is the actual defect. Records are for data; if reading a component can fail, it does not belong in a record.
Interviewer's Next Move
1. "Why not just use an abstract class with a package-private constructor?"
Because that is a convention, not a mechanism, and the compiler cannot reason about it. It confines subclasses to one package rather than to a named list, says nothing about a subtype in a different package of the same module, and gives the exhaustiveness analysis nothing to work with — so every switch still needs a default. Sealing is enforced by javac and by the JVM at class load, and it is what makes the switch check possible.
2. "What does non-sealed mean, and when would you use it?"
It reopens one branch of a sealed hierarchy for unrestricted extension. You use it when most of the set is closed but one case is genuinely an extension point — a Custom node in a document model, say. It is a deliberate hole, and the fact that you had to write the keyword is the point: a permitted subtype cannot stay unspecified, so the hole is always visible in the source.
3. "A record pattern looks like field access. Is it?"
No — it calls the accessor method for each component. Normally that is the generated one and the distinction does not matter, but if you override an accessor, the pattern runs your code. And if your code throws, Java 21 wraps it in a MatchException, so the exception you catch is not the one you threw. It is a strong argument for keeping record accessors trivial.
4. "When does a switch over a sealed type still need a default?"
When the selector's static type is wider than the sealed type — switching on Object rather than on Payment, for instance — because the compiler then has to account for values outside the hierarchy. Also when the hierarchy contains a non-sealed branch whose subtypes are unbounded. If you find yourself writing a default over a fully sealed type, either the selector type is wrong or you have a redundant branch.
5. "You have a sealed interface in a library. What can consumers do to you, and what can you do to them?"
They cannot add a subtype, which is the guarantee. But you should treat adding one yourself as a breaking change: their exhaustive switches stop compiling. That is a source incompatibility, not a binary one — already-compiled consumer code keeps running and hits an IncompatibleClassChangeError or falls through. A sealed public API is a stronger commitment than an open one, and it belongs in the major version.
Code traps
sealed interface Shape permits Circle, Square { }
record Circle(double r) implements Shape { }
record Square(double side) implements Shape { }
static String name(Object o) {
return switch (o) {
case Circle c -> "circle";
case Square s -> "square";
};
}
Answer
It does not compile: the switch expression does not cover all possible input values. The selector is Object, not Shape, so sealing tells the compiler nothing — an Object can be a String. Change the parameter to Shape and it compiles with no default. This is the most common way people conclude that exhaustiveness "doesn't work".
Payment p = new Card("visa", 4242);
String s = switch (p) {
case Card c -> "card";
case Card(String network, int last) -> network;
case Upi u -> "upi";
case Cash c -> "cash";
};
Answer
A compile error on the second case: this case label is dominated by a preceding case label. case Card c already matches every Card, so the record pattern below it is unreachable. The compiler enforces case ordering for patterns, which is why reordering them is a safe refactor — you cannot silently shadow a case the way an if/else if chain lets you.
Common wrong answers
| Said in interviews | Reality |
|---|---|
"Sealed classes are just final for hierarchies." | final prevents all extension. sealed permits a named list, which is what enables the exhaustiveness check. |
| "It's a compile-time-only hint." | The permits list is a class-file attribute and the JVM enforces it at class load. |
"A sealed switch never needs default." | Only when the selector's static type is the sealed type. Switch on Object and you need one. |
| "Sealed is the same as an enum." | An enum's constants are singletons. Sealed types carry per-instance data, which is the whole reason to reach for them. |
| "Permitted subtypes can be anywhere." | Same package, unless you are in a named module. This breaks real migrations. |
| "Adding a permitted subtype is backwards compatible." | It breaks every exhaustive switch in consumer source. Treat it as a major version change. |
Check Yourself
Q1. You add a fourth permitted subtype to a sealed interface used across twelve files. What happens, and why is that better than the alternative?
Answer
Every exhaustive switch over that type stops compiling, and the compiler names each one. The alternative — an open interface with default -> throw — compiles cleanly and fails at runtime, on whichever path the new subtype reaches first, which is typically in production and typically not the path you tested.
Q2. Why must every permitted subtype be final, sealed, or non-sealed?
Answer
Because otherwise the set would not be closed. If Card could be extended freely, the compiler could not claim that handling Card, Upi and Cash covers every possible value. Forcing the declaration means reopening the hierarchy is always an explicit, visible act.
Q3. A switch over a sealed type throws MatchException. Where is the bug?
Answer
In a record accessor, not in the switch. A record pattern invokes each component's accessor while matching; if one throws, Java 21 wraps it in a MatchException whose getCause() is your exception. The fix is to make the accessor trivial — if reading a component can fail, that data does not belong in a record.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Delete a case and read the error | 5 min |
| Challenge | Replace the visitor with a switch | 20 min |
| Production | The payment type that shipped half-handled | 45 min |
| Interview | Full round replay — sealed types | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 16
Records become final, and pattern matching for instanceof lands, so `if (o instanceof Card c)` replaces the test-then-cast pair.
Before Java 16: You wrote instanceof, then a cast on the next line, and the compiler could not tell you when you had forgotten a type.
- Java 17LTS
Sealed classes and interfaces go final. A type can name its permitted subtypes, and the compiler enforces the list.
Before Java 17: The only ways to close a hierarchy were a package-private constructor or an enum — the first is not enforced across modules, the second cannot carry per-case data.
- Java 21LTS
Pattern matching for switch and record patterns go final. A switch over a sealed type is checked for exhaustiveness and needs no default, and record patterns deconstruct in the case label.
Before Java 21: Every switch over a type hierarchy needed a default branch that existed only to satisfy the compiler, and it silently swallowed any case you forgot to add.
- Java 21LTS
MatchException arrives: if a record accessor throws while a pattern is being matched, the failure surfaces wrapped rather than as your own exception.
Before Java 21: There was no pattern matching over records, so the situation could not arise.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
Delete a case and read the error
One concept, guided. Near-impossible to fail.
- Challenge20 min
Retire the visitor
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The payment type that shipped half-handled
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — sealed types
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- pattern matching — not written yet
- version migration — not written yet
- What is type erasure, and what does it prevent?
Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.