Articles
Java Sealed Class Interview Questions: Exhaustive Switches and Pattern Matching
Practice Java sealed class interview questions. Trace exhaustive switches, null handling, guarded patterns, non-sealed branches, and hierarchy evolution.
Java sealed class interview questions test more than whether you remember permits. The useful distinction is between a closed set of direct subtype branches, compile-time switch coverage, and the values that can still arrive at runtime. A switch can be exhaustive without accepting null, and a sealed root can include a branch that remains open to extension.
Start an answer by naming the selector's static type and the language version. Then check permitted branches, dominance, guards, and null separately. If a hierarchy can evolve independently of its consumers, add a deployment and recompilation boundary rather than assuming an old compiled switch remains valid forever.
The examples here use final features available in Java 21: sealed classes became final in Java 17, and pattern matching for switch became final in Java 21. No preview features are required. JEP 409, JEP 441
Java sealed class interview questions: what does permits close?
A sealed class or interface restricts its direct subclasses or implementing classes. Each permitted direct subtype must continue the hierarchy as final, sealed, or non-sealed, subject to the language rules. In a named module the permitted types belong to the same module; in an unnamed module they belong to the same package.
The closure is therefore a branch-admission rule, not a promise that the whole object graph is immutable or that every descendant is individually listed at the root. A final branch stops extension. A sealed branch applies another restriction. A non-sealed branch reopens extension below that point. Sealed-class design and restrictions
Records are useful final leaves, but sealing does not require every leaf to be a record. A permitted class can hold mutable state, implement complex behavior, or refer to mutable collections. Do not confuse a closed hierarchy with value immutability, thread safety, or defensive copying.
Consider this original result hierarchy:
sealed interface Result permits Success, Failure {}
record Success(int value) implements Result {}
non-sealed class Failure implements Result {}
final class RetryableFailure extends Failure {}
The root admits Success and Failure directly. RetryableFailure belongs under Failure and need not be added to the root's permits list. If failures should themselves be a closed vocabulary, make Failure sealed and enumerate its direct branches instead.
When is a pattern switch exhaustive?
For the hierarchy above, a switch covering Success and Failure can cover every non-null Result value, including RetryableFailure:
static String describe(Result result) {
return switch (result) {
case Success s -> "value=" + s.value();
case Failure f -> "failed";
};
}
The Failure pattern covers its subclasses. Exhaustiveness does not require a case for every possible descendant beneath that non-sealed branch. This is the first useful follow-up to “sealed means a fixed number of concrete implementations.”
Now remove the Failure case. The compiler cannot prove coverage of Result from Success alone. Adding a broad default makes the switch compile, but it also makes that omission less visible. Choose a catch-all when it represents a deliberate domain or compatibility policy, not merely to silence a useful diagnostic.
The coverage analysis uses the selector's static type. Switching on Object is a different problem from switching on Result, even when your test values happen to be the same. Generics and nested record patterns introduce additional rules; reason from the exact declared types rather than copying a two-case example mechanically. Oracle pattern-switch guide
Exhaustiveness is not null safety
Passing null to the first switch does not select Failure or a hidden universal fallback. Without an applicable null label, the switch throws NullPointerException. A plain default is not a null handler. Add case null or reject null before the switch if that is your contract.
static String describeNullable(Result result) {
return switch (result) {
case null -> "missing";
case Success s -> "value=" + s.value();
case Failure f -> "failed";
};
}
Whether missing should be a valid result is a domain decision. If null is a programming error, an explicit non-null check documents that choice. If missing is meaningful, a separate permitted result type can make it visible to ordinary exhaustiveness checks rather than using null as an undocumented extra state.
Keep these two questions separate in your answer: “Does every permitted non-null branch match?” and “What is the selector-null policy?” Combining them into “the switch handles everything” is the interview mistake this example is designed to expose.
Dominance: why does case order matter?
Put a Failure pattern before RetryableFailure and the broader pattern already accepts every retryable instance. A later RetryableFailure label is dominated and rejected. Place the more specific pattern first if it has a distinct outcome, then let Failure handle the rest.
This is not ordinary statement execution after a successful match. It is compile-time reasoning about which values can ever reach a label. The compiler uses dominance rules to prevent cases whose patterns are already covered by earlier labels.
| Proposed cases | Result or diagnostic | Reason |
|---|---|---|
| Success, then Failure | Exhaustive for non-null Result | Both root branches are covered. |
| Failure, then RetryableFailure | Dominance error | The first pattern already covers the subtype. |
| RetryableFailure, then Failure, then Success | Useful ordered coverage | Specific failure behavior precedes the general failure branch. |
| Positive guarded Success, then Failure | Not exhaustive | Other Success values remain uncovered. |
| Positive guarded Success, then unguarded Success, then Failure | Complete non-null coverage | The second Success label handles remaining successes. |
When guards are involved, do not invent a general theorem prover. A guard such as s.value() > 0 is not treated as proving coverage of every Success. The language gives specific dominance and coverage rules, including special treatment for a constant true guard. Check the actual rule, not an intuition that two predicates “probably cover all integers.” Switch language rules
Guarded patterns divide a branch; they do not replace it
Add a business requirement: positive successes need a different message from zero or negative successes. The safe structure is a guarded case followed by an unguarded case for the same type.
case Success s when s.value() > 0 -> "positive";
case Success s -> "non-positive";
case Failure f -> "failed";
Explain the three partitions before discussing syntax. The guard divides Success by a value condition. The unguarded Success restores coverage of that type. Failure covers the other root branch. Selector null still needs its own handling.
A guard can call code, and that code may throw or have side effects. Exhaustiveness does not mean evaluation is exception-free. Prefer straightforward predicates in interview examples and state what a failure in the predicate means rather than assuming the next case is a universal recovery mechanism.
Test positive, zero, and negative values. A suite with only positive successes and failures cannot distinguish a complete switch from the incorrectly omitted non-positive branch. Compiler-negative fixtures are also useful: deliberately remove the unguarded Success and confirm that compilation fails.
Nested record patterns create another null boundary
Wrap Result inside a record:
record Box(Result result) {}
A switch with case Box(Success s) and case Box(Failure f) can be considered exhaustive for Box, yet a non-null new Box(null) does not match either nested type pattern. If no label applies, the enhanced switch can throw MatchException. This is different from a null selector's NullPointerException.
An unconditional component pattern such as case Box(var r) can match the null component and lets the branch explicitly inspect it. A default can cover the non-null Box whose nested component fails the narrower patterns, but still does not make a null selector safe. Oracle nested-pattern guidance, MatchException API
| Runtime input | Narrow nested patterns only | Explicit policy to consider |
|---|---|---|
| Box containing Success | Success component pattern | Normal value branch. |
| Box containing Failure | Failure component pattern | Normal failure branch. |
| Box containing null | Can reach MatchException | Match a broad component and handle or reject null. |
| Null Box selector | NullPointerException without null handling | Add case null or check before switching. |
Record accessors used during matching can also throw; the specified pattern-matching failure behavior is not an ordinary failed boolean test in every case. Keep the exercise's accessor behavior simple, and do not claim that a record pattern is harmless just because the record has few components.
What happens when a hierarchy evolves?
Imagine version one permits only Success and Failure. A consumer compiles an exhaustive switch without default. Version two adds a new direct result type, such as Cancelled, and the consumer is recompiled against it. The missing branch can now be a compile-time error: that is the useful maintenance benefit of explicit coverage.
Separate recompilation from running an old binary against a changed library. The old switch was compiled using the earlier hierarchy. If the new subtype reaches it and none of its existing labels applies, the runtime can throw MatchException. Compile-time exhaustiveness is not a guarantee against every separate-compilation change. JEP 441
Practice this with two small build directories. Compile the version-one hierarchy and consumer together. Then compile only the compatible changed hierarchy and its new subtype into the runtime directory, leaving the old consumer untouched. Call the old consumer with the new value and record the observed exception. Finally recompile the consumer and inspect the coverage diagnostic.
Do not “fix” the experiment by recompiling everything before the old-binary step: that erases the boundary you are testing. Record which class files came from which version, and avoid presenting a whole-application rebuild as proof that mixed-version deployment is safe.
Whether a default is appropriate depends on the API policy. A closed internal model may prefer compiler pressure to update every consumer. A versioned external protocol may require an explicit unknown-result branch. In both cases, preserve enough information for the caller to decide what happens next.
An interview answer that survives follow-ups
Say: “Sealing restricts direct subtype admission. I can switch over those branches without a default when coverage is complete, including covering an open branch with its supertype pattern. I still need separate policies for null, guards, nested patterns, and separately deployed hierarchy changes.”
Then draw the Result tree and predict four inputs: Success, RetryableFailure, null, and a future Cancelled value reaching an old consumer. That trace is more convincing than a definition alone.
Sources and Further Reading
- OpenJDK JEP 409: Sealed Classes
- OpenJDK JEP 441: Pattern Matching for switch
- Oracle Java 25: Pattern Matching for switch
- Java Language Specification: statements and switch
- Java MatchException API and failure boundaries
Technical references checked October 3, 2026. Examples use Java 21 final features; Java 25 reference text is not a claim that a preview feature is required.