Sealed Classes and Pattern Matching — Junior¶
What? Sealed types are classes or interfaces that declare an explicit, closed list of permitted direct subtypes via a
permitsclause. Pattern matching — forinstanceofandswitch— destructures and tests those subtypes in a way the compiler can check for completeness. Together, they give you the slogan closed inheritance, exhaustive dispatch: you decide upfront which variants exist, and the compiler refuses to let you forget one. How? Mark a parent typesealedand list its children inpermits. Mark each childfinal,sealed(with its ownpermits), ornon-sealed. Switch over the parent with patterns:case Circle c -> ...; case Square s -> ...;. If you cover every permitted child, nodefaultis needed — and adding a new child breaks every switch until you update it.
1. The point of sealed types in one sentence¶
Open inheritance — class Foo extends Bar from anywhere on the classpath — is a powerful but expensive default. It means a library author who ships Bar can never know all of its subclasses, the compiler can never check that a switch covers every case, and the JIT cannot devirtualize call sites without aggressive profiling. Sealed types invert that default: you opt in to extension, by name, in the parent's source file.
In return, the compiler can prove your switch is exhaustive, the JIT can specialize dispatch over a finite set, and your future self can read one file and know every shape this type can take. Sealed + records + pattern switch is Java's spelling of algebraic data types — sum types (sealed) of product types (record).
The feature was previewed in Java 15 (JEP 360), refined in Java 16 (JEP 397), and finalized in Java 17 (JEP 409). Pattern matching for instanceof shipped in Java 16 (JEP 394); pattern matching for switch previewed in Java 17 (JEP 406) and finalized in Java 21 (JEP 441).
2. The first example — Shape¶
public sealed interface Shape permits Circle, Square, Triangle {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
Three things are happening:
Shapeissealed. Only the types named inpermitsmay implement it. A fourthclass Hexagon implements Shape {}would not compile.- Each permitted child is
final(records are implicitly final). That ends the hierarchy — nopermitsclause is needed on a leaf. - The list is visible in the parent's source. Open a single file and you see the full sum.
You can also seal classes, not just interfaces:
public sealed abstract class Vehicle permits Car, Truck, Motorcycle {}
public final class Car extends Vehicle {}
public final class Truck extends Vehicle {}
public final class Motorcycle extends Vehicle {}
The mechanics are identical: a closed list of children, the compiler enforces it.
3. Pattern matching for instanceof¶
Before Java 16 you wrote a redundant cast after every instanceof:
// Pre-16 — write the same type twice
if (s instanceof Circle) {
Circle c = (Circle) s; // cast, even though instanceof just confirmed it
System.out.println(c.radius());
}
Java 16 introduced the type pattern (JEP 394). Bind the test result to a variable in one step:
if (s instanceof Circle c) { // c is in scope only if the test passed
System.out.println(c.radius());
}
The variable c is definitely assigned and typed as Circle inside the if branch. Outside the branch, c is out of scope. The cast is gone and the typo opportunity is gone with it.
4. Pattern matching for switch¶
The bigger payoff is pattern matching in switch expressions (JEP 441, final in Java 21):
public static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
Read each case as "if s is of this shape, bind it to this name, evaluate this expression". The compiler verifies that every permitted subtype of Shape appears at least once. Forget Triangle and you get:
No default is needed. The compiler proved you covered everything.
5. The newcomer surprise — no default for exhaustive sealed switches¶
Pre-21 Java taught you that every switch needs a default. With sealed types and pattern matching, this is no longer true — and you usually don't want one. Compare:
// Wrong instinct — adds a default "just in case"
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Triangle t -> 0.5 * t.base() * t.height();
default -> 0.0; // silently swallows new shapes
};
// Right instinct — let the compiler check completeness
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Triangle t -> 0.5 * t.base() * t.height();
};
The second form is better than the first. If someone later adds Pentagon to permits, the with-default version silently returns 0.0 for pentagons; the no-default version refuses to compile until you handle the new case. Letting the compiler track completeness is the entire point.
6. The three modifiers permitted children must pick¶
Every named permit must declare one of three modifiers:
| Modifier | Means |
|---|---|
final | This child has no further subclasses. The hierarchy stops here. |
sealed | This child has its own permits list. Extension is deeper but still closed. |
non-sealed | This child opens up again — anyone may extend it. Use sparingly. |
public sealed interface Animal permits Mammal, Bird {}
public sealed interface Mammal extends Animal permits Dog, Cat {}
public record Dog() implements Mammal {}
public record Cat() implements Mammal {}
public non-sealed interface Bird extends Animal {} // anyone can be a Bird now
A child without one of the three modifiers does not compile. That forces you to declare your closure intent explicitly at every level.
7. Why no default is a feature, not a missing safety net¶
The compiler tracks permits as part of Shape's type information. When it sees switch (s) over Shape, it asks: "have you handled every permitted subtype?" If yes, the switch is exhaustive and complete; if no, compilation fails.
This is more than convenience. It means the act of adding a new variant surfaces every place in your codebase that needs updating. Add Pentagon to permits, recompile, and you get a list of every switch that needs a new case. With a string-keyed switch or a default-swallowing version, you would never find them all.
This compiler-enforced completeness is what people mean when they say sealed types give you algebraic data types: like Haskell's data, OCaml's variants, or Rust's enum, the language refuses to let you forget a case.
8. A tiny realistic example — a Result type¶
A common idiom: return either a success or a failure without exceptions.
public sealed interface Result<T> permits Result.Success, Result.Failure {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String message) implements Result<T> {}
}
public static <T> void handle(Result<T> r) {
switch (r) {
case Result.Success<T> s -> System.out.println("ok: " + s.value());
case Result.Failure<T> f -> System.err.println("fail: " + f.message());
}
}
Two children, both records, declared right inside the interface. Callers cannot invent a third variant; the compiler enforces both cases on every switch. We expand Result<T, E> and explore richer error modelling in middle.md.
9. Common newcomer mistakes¶
Mistake 1: forgetting to seal the children.
sealed interface Op permits Add, Sub {}
class Add implements Op {} // compile error — no final/sealed/non-sealed
Every permitted child must pick exactly one of final, sealed, or non-sealed. Records satisfy this automatically (records are implicitly final).
Mistake 2: adding default "for safety".
return switch (shape) {
case Circle c -> area(c);
case Square s -> area(s);
default -> 0.0; // hides the next variant from the compiler
};
Delete the default if your switch covers a sealed type. The compiler is your safety net; the default removes it.
Mistake 3: permitting from a different package without a module.
If Shape and Circle are in different packages of the same unnamed module, the compiler complains. Same-package or same-module is required. See specification.md for the exact rule.
Mistake 4: confusing final and sealed.
final and sealed are different statements. final says "no children". sealed says "only the children I name". A sealed parent and a final child both contribute to closure but at different levels.
10. Quick rules¶
- Use
sealedwhenever you have a closed set of subtypes you control — payment methods, AST nodes, command types, result variants. - Pair
sealedwithrecordwhenever children are data carriers — you get product+sum types in two keywords. - Every permitted child must pick
final,sealed, ornon-sealed. Records are implicitlyfinal. - In a
switchover a sealed type, omitdefault. Let the compiler enforce exhaustiveness. - Use
if (x instanceof Foo f)to shrink instanceof-then-cast chains. -
permitsis part of the parent's contract — adding to it can be a breaking change for downstream consumers (see senior.md).
11. What's next¶
| Topic | File |
|---|---|
Result<T, E>, ADTs, record patterns, refactoring instanceof chains | middle.md |
Closed-world dispatch internals, non-sealed, binary compat | senior.md |
| Code-review vocabulary, ArchUnit rules, migration | professional.md |
| JLS §8.1.1.2, §9.1.1.4, JVMS §4.7.31, JEPs 360/397/409/394/406/440/441 | specification.md |
| Sealed hierarchies that bite at runtime | find-bug.md |
SwitchBootstraps.typeSwitch, devirtualization, JMH benchmarks | optimize.md |
| Hands-on exercises | tasks.md |
| Interview Q&A | interview.md |
Cross-references:
- Sealed types are the modern composition over inheritance answer for closed taxonomies — see ../../03-design-principles/02-composition-over-inheritance/.
- Pattern-match
switchlowers toinvokedynamicwithSwitchBootstraps.typeSwitch— see ../../06-method-dispatch-and-internals/01-jvm-method-dispatch/. - Module-system rules constrain where permitted subclasses may live — see ../02-jpms-modules/.
Memorize this: sealed types are closed inheritance by design — you list the children in permits, each child picks final/sealed/non-sealed, and the compiler enforces it. Pattern matching turns that closure into exhaustive dispatch — every switch is checked for completeness, no default needed. Together they give Java algebraic data types: sum-of-products you can read in one file and update with the compiler's help.