Sealed Interfaces + Exhaustive Switch: The Pattern the Senior Java Interview Expects
Six progressively harder questions — from exhaustiveness guarantees and non-sealed subtypes to generic bounds and cross-package visibility. Everything a senior engineer is expected to know cold in 2026.
Sealed interfaces landed as a stable feature in Java 17 (JEP 409) and became production baseline in Java 21 once pattern matching for switch was finalized in JEP 441. By mid-2026 they appear in virtually every modern Java codebase — in result types, event hierarchies, domain models, and anywhere else you previously reached for a boolean flag or a wide-open interface with an instanceof chain.
Despite that, adoption in the wild lags well behind records. Survey data from BellSoft’s 2024 developer study puts sealed class adoption in the low-to-mid teens percentage-wise, compared to over 55% for records. The gap closes every year, but it also means senior interviews are increasingly using sealed interfaces as a filter. The concept is simple enough to describe in a sentence, yet the edge cases — around exhaustiveness guarantees, cross-module visibility, non-sealed subtypes, and generics — are genuinely tricky. That is exactly where interviews go.
This article walks through six questions in order of difficulty. Each one starts with the question a senior interviewer is likely to ask, followed by what a strong answer looks like. Read them in sequence: the later questions build on concepts established in the earlier ones.
| # | Topic | Difficulty | What it tests |
|---|---|---|---|
| 1 | Compiler exhaustiveness guarantee | Starter | Foundational understanding of sealed + switch interaction |
| 2 | The non-sealed modifier escape hatch | Starter | Understanding all three permitted-subtype modifiers |
| 3 | Adding a new subtype: compile vs runtime | Mid | Separate compilation model and MatchException |
| 4 | Permitted subtypes in a different package | Mid | Package and module rules; accessibility vs exhaustiveness |
| 5 | Nested sealed hierarchies | Senior | How the compiler recursively resolves deep hierarchies |
| 6 | Generic bounds + sealed: when a case disappears | Senior | Type parameter narrowing that eliminates entire arms |
1. What exactly does the compiler guarantee when you write an exhaustive switch over a sealed interface — and what does it not guarantee?
The compiler guarantees that, at compile time, every permitted direct subtype listed in the sealed interface’s permits clause is covered by at least one case arm. If any permitted type is missing and there is no default clause, the code does not compile. That is the core promise: you cannot accidentally forget a variant at the source level.
// Java 21+ — both in the same compilation unit
sealed interface PaymentResult permits Success, Declined, Pending {}
record Success(String txId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(String ref) implements PaymentResult {}
// This compiles — all three variants covered
String describe(PaymentResult r) {
return switch (r) {
case Success s -> "Paid: " + s.txId();
case Declined d -> "Refused: " + d.reason();
case Pending p -> "Waiting: " + p.ref();
// no default needed — compiler sees all three
};
}
Importantly, what the compiler does not guarantee is that this switch stays correct at runtime if the sealed hierarchy is changed after the switch was compiled. The compiler inserts a synthetic default branch that throws java.lang.MatchException to handle that scenario. In other words, the guarantee is a compile-time contract, not an iron-clad runtime invariant — a distinction that matters for separate compilation and library versioning.
Key phrase for the interview: “Exhaustiveness is a compile-time check over the permits clause at the time of compilation. The JVM inserts a hidden throwing default to protect against stale bytecode.”
2. A permitted subtype is declared non-sealed. What happens to the exhaustiveness guarantee at that point?
Every permitted subtype must declare exactly one of three modifiers: final (no further subclassing), sealed (further subclassing allowed but only to a declared list), or non-sealed (fully open — any class can extend it). When a subtype is non-sealed, the compiler knows it can have an unlimited number of concrete subtypes that it cannot enumerate. Therefore, exhaustiveness over the parent interface is immediately broken: the compiler cannot guarantee that all cases are handled, and it will require a default clause.
sealed interface Shape permits Circle, Polygon {}
// final — compiler knows everything about Circle
record Circle(double radius) implements Shape {}
// non-sealed — Polygon can be subclassed freely by anyone
non-sealed abstract class Polygon implements Shape {
public abstract int sides();
}
// This does NOT compile without a default —
// Polygon could have unknown subtypes (Hexagon, Octagon, etc.)
String area(Shape s) {
return switch (s) {
case Circle c -> "circle with r=" + c.radius();
// case Polygon p -> ... // not enough — must add default
default -> "some polygon";
};
}
The three modifiers reflect three architectural decisions. final is a leaf node — perfect for record-based value types. sealed allows controlled recursion of the hierarchy. non-sealed deliberately re-opens extension to the world, which is useful when you want one well-known implementation to be extensible by library consumers, while keeping the rest of the hierarchy closed.
Common mistake: Forgetting that non-sealed infects the exhaustiveness check of the parent switch, not just the subtype’s own switch. One non-sealed permitted type is enough to force a default clause on every switch over the top-level sealed interface.
Permitted Subtype Modifier — What Each One Allows

3. Your library ships a sealed interface. Six months later you add a new permitted subtype and release a patch. A downstream service that was not recompiled now throws at runtime. What is happening and why?
This is the separate compilation problem. When the downstream service compiled its exhaustive switch, the permits clause declared two subtypes, so the compiler was satisfied. It generated bytecode with no explicit default clause — but the JVM, to protect against exactly this scenario, silently synthesizes one that throws java.lang.MatchException. When the new third variant flows through the switch at runtime, none of the two compiled arms match, the synthetic default fires, and the service gets an unchecked MatchException.
// v1.0 — shipped in your library
sealed interface Event permits OrderPlaced, OrderCancelled {}
record OrderPlaced(String id) implements Event {}
record OrderCancelled(String id) implements Event {}
// downstream compiled against v1.0 — exhaustive, compiles fine
String handle(Event e) {
return switch (e) {
case OrderPlaced p -> "place " + p.id();
case OrderCancelled c -> "cancel " + c.id();
// no default — compiler was happy with v1.0
};
}
// v1.1 — you add this without recompiling downstream
// record OrderShipped(String id) implements Event {}
// At runtime, new OrderShipped("42") hits handle() -->
// --> MatchException thrown from the synthetic default
The fix is straightforward in one direction: always recompile dependents when you modify a sealed hierarchy. That is easy in a monorepo. Across independent libraries it becomes a semver breaking change — adding a new permitted type to a public sealed interface is a major version bump under Semantic Versioning, not a patch or minor.
The practical mitigation for library authors is to add a default clause to the downstream switch if the sealed type comes from an external module you do not control. That sacrifices the exhaustiveness guarantee but avoids the runtime surprise. Some teams adopt the convention of sealing hierarchies only for types that are truly internal to a module, and using open interfaces for any API surface that might grow.
Interview follow-up: The interviewer may ask what MatchException is. It is a new unchecked exception introduced in Java 21 specifically for this case — an exhaustive switch construct that encounters a value it cannot match at runtime. It is not the same as IllegalStateException or an assertion error; it signals a stale-bytecode mismatch.
4. Can permitted subtypes live in a different package from the sealed interface? What are the constraints, and how does it affect exhaustiveness?
The answer depends on whether the sealed interface is in a named module or in the unnamed module (the classpath). In a named module, all permitted subtypes must reside in the same module — but they can be in different packages within that module. In the unnamed module, all permitted subtypes must be in the same package as the sealed type. Violating either rule is a compile-time error.
| Deployment context | Same package required? | Cross-package permitted? |
|---|---|---|
| Unnamed module (classpath) | Yes | No |
| Named module (module-info.java) | No | Yes — same module |
There is a further subtlety around accessibility. The OpenJDK design notes are explicit: permitted subtypes can be less accessible than the sealed parent. For example, a public sealed interface can have a package-private permitted subtype. In that case, any code outside the package can reference the sealed interface, but cannot see the package-private subtype — meaning it cannot write an exhaustive switch and must include a default clause. The compiler enforces this gracefully: it demands a default where it cannot verify coverage.
// package com.example.orders (named module com.example)
public sealed interface OrderStatus
permits Confirmed, Rejected, com.example.internal.Pending {}
// com/example/internal/Pending.java
// package-private — callers outside com.example.internal
// cannot see this type
final class Pending implements OrderStatus {}
// External consumer — cannot write exhaustive switch:
// case Pending is invisible ? must add default
String render(OrderStatus s) {
return switch (s) {
case Confirmed c -> "confirmed";
case Rejected r -> "rejected";
default -> "other"; // required — Pending is not visible here
};
}
Deliberately hiding a subtype behind package-private access is a valid pattern. It lets you extend the hierarchy later without breaking external callers, since they already needed a
defaultclause. The trade-off is that external consumers cannot write tight exhaustive switches.
5. You have a nested sealed hierarchy — a permitted subtype is itself sealed with its own subtypes. How does the compiler resolve exhaustiveness? What must your switch cover?
When a permitted subtype is itself sealed, the compiler recurses into its permitted subtypes to determine whether the overall switch is exhaustive. However, there is an important nuance: you have a choice of whether to match at the intermediate level or drill all the way down to the leaf types. Both can be exhaustive, depending on what you match.
sealed interface Vehicle permits Car, Truck, Motorcycle {}
// Car is a leaf
record Car(String model) implements Vehicle {}
// Truck is sealed — has its own permitted subtypes
sealed class Truck implements Vehicle permits Pickup, Lorry {}
final class Pickup extends Truck {}
final class Lorry extends Truck {}
// Motorcycle is non-sealed — opens extension back up
non-sealed class Motorcycle implements Vehicle {}
// Option A: match Truck as a whole (covers both Pickup and Lorry)
// But Motorcycle is non-sealed, so a default is required regardless
String describeA(Vehicle v) {
return switch (v) {
case Car c -> "car: " + c.model();
case Truck t -> "some truck"; // matches Pickup and Lorry
case Motorcycle m -> "bike";
// No default needed for Car/Truck — but Motorcycle is non-sealed,
// so the default below covers unknown Motorcycle subtypes
default -> "unknown vehicle";
};
}
// Option B: drill to leaves within Truck
String describeB(Vehicle v) {
return switch (v) {
case Car c -> "car: " + c.model();
case Pickup p -> "pickup";
case Lorry l -> "lorry";
case Motorcycle m -> "bike";
default -> "unknown vehicle"; // still needed for non-sealed Motorcycle
};
}
The exhaustiveness algorithm the compiler uses is recursive: a set of patterns is exhaustive for a sealed type if it is exhaustive for every permitted direct subtype. Matching Truck t covers both Pickup and Lorry because Truck is their sealed parent — any value that is a Pickup or Lorry is also a Truck, so the pattern is total for those two. The presence of non-sealed Motorcycle, however, breaks overall exhaustiveness, because neither the compiler nor the switch can enumerate all possible Motorcycle subtypes.
A switch is exhaustive over a sealed type if and only if every leaf of the hierarchy — tracing down all sealed sub-hierarchies recursively — is covered by at least one pattern, either directly or by a parent pattern that is total over it. One
non-sealednode anywhere in the reachable hierarchy forces adefault.
Exhaustiveness Coverage by Scenario — Can you omit the default clause?

6. Given a sealed interface with two permitted types — one that implements it only for a specific type parameter — the compiler accepts an exhaustive switch with a single case arm. Walk through exactly why.
This is the most subtle question of the set, and it comes directly from the Java Language Specification’s treatment of generic sealed types. Consider this hierarchy, taken from the official Oracle pattern matching documentation:
// Sealed generic interface — two permitted types
sealed interface Container<T> permits Box, Bag {}
// Box only implements Container for String (bound to a specific type arg)
final class Box<X> implements Container<String> {}
// Bag implements Container for any T (covariant)
final class Bag<Y> implements Container<Y> {}
// Switch over Container<Integer>
// The compiler knows: Box implements Container<String>,
// so Box is NOT a possible runtime type for Container<Integer>.
// Only Bag<Integer> can satisfy this type parameter.
static int handle(Container<Integer> c) {
return switch (c) {
case Bag<Integer> b -> 42;
// No case Box needed — Box<Integer> is impossible
// by type argument incompatibility
};
}
The compiler is doing genuine type narrowing here. Because Box<X> always implements Container<String> — regardless of what X is — a Box can never be an instance of Container<Integer>. Therefore, the only possible runtime type for a Container<Integer> is Bag<Integer>. The switch with a single arm is exhaustive, and the compiler accepts it without complaint.
This matters in real code. Consider a result type like sealed interface Result<T> permits Ok, Err where Ok<T> carries a T value and Err always implements Result<Void>. A switch over Result<String> can eliminate the Err arm entirely by type argument reasoning — giving you tighter, compiler-verified code in transformation pipelines.
// Real-world analog: result type with type-bound error
sealed interface Result<T> permits Ok, Err {}
final class Ok<T> implements Result<T> { final T value; Ok(T v) { value = v; } }
// Err always wraps Throwable, bound to Void on the success type
final class Err implements Result<Void> { final Throwable cause; Err(Throwable t) { cause = t; } }
// For Result<String>, Err is impossible — Err implements Result<Void>, not Result<String>
String extract(Result<String> r) {
return switch (r) {
case Ok<String> ok -> ok.value;
// Err arm not needed — Result<String> can never be an Err at runtime
};
}
Why this comes up in interviews: Most candidates know sealed interfaces reduce boilerplate. Senior engineers are expected to know that the compiler applies type-parameter narrowing to sealed hierarchies, producing switches that are more exhaustive than they look at first glance. This is one of the few places in Java where generics and the type hierarchy actively cooperate at compile time rather than just erasing.
Putting It All Together: When to Seal and When Not To
Understanding the mechanics of these six questions also clarifies the architectural decision. Sealing a type is, fundamentally, a trade-off between two axes of extensibility. An open interface says: “callers can add new implementations.” A sealed interface says: “callers can add new operations.” The switch over a sealed type is a new operation — and callers can add as many as they like without touching the hierarchy. What they cannot do is add a new variant without your cooperation.
That makes sealed interfaces the right choice in several common scenarios: domain state machines where the set of states is fixed by the business domain, result and error types where every consumer must handle every outcome, command or event types in CQRS pipelines, and AST node types in compilers and interpreters. It makes sealed interfaces a poor choice for plugin APIs, strategy patterns, and any type whose extension set should be determined by the caller.
| Use case | Seal? | Reason |
|---|---|---|
| Payment / order result type | Yes | Fixed set of outcomes; exhaustive handling is the point |
| AST nodes in a compiler | Yes | Internal; all operations are new switch arms |
| CQRS command or event | Yes | Handlers must process every shape; breaks-on-add is a feature |
| Plugin / extension interface | No | Callers are supposed to add implementations |
| Strategy / policy interface | No | Open set by design; sealing defeats the purpose |
| Public library API type | Careful | Adding a subtype is a breaking change; version carefully |
What We Learned
Sealed interfaces and exhaustive switch expressions form a compiler-enforced contract that turns missed cases from a runtime surprise into a build failure. We worked through six questions that progress from foundational to genuinely subtle. We saw that exhaustiveness is a compile-time guarantee over the permits clause at the moment of compilation, not a runtime invariant — and that MatchException is the safety net the JVM inserts for stale bytecode.
We examined how non-sealed subtypes break exhaustiveness upstream, how nested sealed hierarchies are resolved recursively, and how cross-package and cross-module visibility rules constrain which consumers can even write exhaustive switches. Finally, we saw the most advanced case: the compiler uses generic type-argument narrowing to prove that certain permitted types are impossible for a given parameterization, making single-arm switches legitimately exhaustive. Together, these rules define a feature that is simple to introduce but surprisingly deep to master — exactly the kind of topic a senior interview is designed to probe.

