Why this matters
- Before sealed types, a public interface could be implemented by anyone, so every
switchover its subtypes needed a defensivedefaultthat silently swallowed new cases. - Sealing plus records plus pattern matching is the combination behind data-oriented programming in modern Java, and the three features were designed together.
- It makes a design decision explicit in the code: this hierarchy is a closed set of alternatives, not an extension point.
The problem
public interface Shape { } // anyone can implement this
double area(Shape shape) {
if (shape instanceof Circle c) return Math.PI * c.radius() * c.radius();
if (shape instanceof Square s) return s.side() * s.side();
throw new IllegalArgumentException("unknown shape: " + shape); // a runtime landmine
}
A new Triangle compiles fine everywhere and fails at runtime, possibly months later. The compiler cannot help,
because it has no idea how many shapes exist.
Sealing the hierarchy
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 { }
Any other type that tries to implement Shape fails to compile — the hierarchy cannot be extended from outside.
Now the compiler knows there are exactly three shapes, and it can prove a switch is complete.
double area(Shape shape) {
return switch (shape) { // no default needed
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
Add a fourth shape to the permits clause and this method stops compiling until you handle it. Every switch
over Shape across the whole codebase does the same. That is the entire point.
The rules
What the compiler enforces
- A
sealedtype must list its direct subtypes inpermits, unless they are all in the same file, in which casepermitsmay be omitted. - Every permitted subtype must be in the same module, or the same package if there is no module.
- Every permitted subtype must actually extend or implement the sealed type — a mismatch is an error.
- Every permitted subtype must choose one of three options:
final,sealed(continuing the restriction), ornon-sealed(reopening that branch). - Records and enums are implicitly final, so they satisfy the requirement with no extra keyword.
// permits omitted — all subtypes live in this file
sealed interface Result {
record Success(String payload) implements Result { }
record Failure(String reason, Throwable cause) implements Result { }
}
// Each branch declares its own policy
public sealed class Vehicle permits Car, Truck, Motorcycle { }
public final class Car extends Vehicle { } // closed here
public sealed class Truck extends Vehicle permits PickupTruck, SemiTruck { }
public final class PickupTruck extends Truck { }
public final class SemiTruck extends Truck { }
public non-sealed class Motorcycle extends Vehicle { } // deliberately reopened
Sealed versus the alternatives
| Aspect | Sealed hierarchy | Enum |
|---|---|---|
| Instances | Unbounded — many Circles with different radii | A fixed set of singletons |
| State | Each subtype carries its own fields | Shared fields across all constants |
| Exhaustiveness | Checked by the compiler in switch | Checked by the compiler in switch |
| Good for | Alternative shapes of data — Success or Failure | A closed set of named values — Status, Day |
Instances
Sealed hierarchyUnbounded — many Circles with different radiiEnumA fixed set of singletonsState
Sealed hierarchyEach subtype carries its own fieldsEnumShared fields across all constantsExhaustiveness
Sealed hierarchyChecked by the compiler in switchEnumChecked by the compiler in switchGood for
Sealed hierarchyAlternative shapes of data — Success or FailureEnumA closed set of named values — Status, Day
An enum closes the set of values; a sealed type closes the set of shapes.
| Aspect | Sealed + switch | Abstract method + polymorphism |
|---|---|---|
| Adding a subtype | Every switch breaks until updated — visible | Compiler forces you to implement the method |
| Adding an operation | Write one new method in one place | Must touch every single subtype |
| Where logic lives | Outside the data, grouped by operation | Inside each type, grouped by type |
| Best when | Data is stable, operations keep growing | Operations are stable, types keep growing |
Adding a subtype
Sealed + switchEvery switch breaks until updated — visibleAbstract method + polymorphismCompiler forces you to implement the methodAdding an operation
Sealed + switchWrite one new method in one placeAbstract method + polymorphismMust touch every single subtypeWhere logic lives
Sealed + switchOutside the data, grouped by operationAbstract method + polymorphismInside each type, grouped by typeBest when
Sealed + switchData is stable, operations keep growingAbstract method + polymorphismOperations are stable, types keep growing
Neither replaces the other. Choose based on which axis changes more often.
A realistic use
Modelling an operation that can succeed, fail, or still be running:
public sealed interface JobState {
record Queued(Instant since) implements JobState { }
record Running(Instant started, int percent) implements JobState { }
record Completed(Instant finished, String output) implements JobState { }
record Failed(Instant finished, String reason) implements JobState { }
}
String describe(JobState state) {
return switch (state) {
case Queued q -> "waiting since " + q.since();
case Running r -> r.percent() + "% done";
case Completed c -> "finished: " + c.output();
case Failed f -> "failed: " + f.reason();
};
}
boolean isTerminal(JobState state) {
return switch (state) {
case Completed ignored, Failed ignored2 -> true;
case Queued ignored, Running ignored2 -> false;
};
}
Compare that to a single mutable Job class with a status enum and four nullable fields, where output is only
meaningful for one status and nothing stops you reading it in the others. The sealed version makes invalid
combinations unrepresentable.
What it looks like at runtime
Class<?> type = Shape.class;
System.out.println(type.isSealed()); // true
System.out.println(Arrays.toString(type.getPermittedSubclasses()));
The permitted list is recorded in the class file as a PermittedSubclasses attribute, so the JVM enforces it
too. Sealing is not only a compile-time convention — loading an unlisted subtype fails verification.
Common misreadings
- "Sealed means final."
finalallows no subtypes;sealedallows a known list. - "
permitsis always required." It can be omitted when every subtype is in the same source file. - "Permitted subtypes can live anywhere." They must be in the same module, or the same package without modules.
- "Sealing forces exhaustiveness everywhere." A
non-sealedbranch reopens the hierarchy and bringsdefaultback. - "Sealed types replace enums." An enum closes a set of values; a sealed type closes a set of shapes.
- "Sealed classes replace polymorphism." They are complementary — choose based on whether types or operations change more.
- "It is only a compiler check." The permitted list is in the class file and the JVM enforces it.
Quick recall
Everything you need if you only revisit this box.
sealedrestricts which types may extend or implement, listed inpermits— omittable when all subtypes share the file.- Every permitted subtype must be
final,sealed, ornon-sealed, and must live in the same module or package. - Records and enums are implicitly final, which is why sealed interface + records is the standard pairing.
- The payoff is compiler-checked exhaustiveness in
switch, so nodefaultis needed and new subtypes break the build until handled. non-sealedreopens a branch and loses exhaustiveness through it.- Use a sealed type for alternative shapes of data; use an enum for a closed set of named values.
- Prefer sealed + switch when operations grow; prefer abstract methods when types grow.
- The
permitslist is recorded in the class file and enforced by the JVM.
Test yourself
Answer these before moving on — recall is what makes it stick.