Java Sealed Types In Depth
Model closed type hierarchies in Java with sealed classes and interfaces, especially with pattern matching.
Java Sealed Types In Depth
Sealed classes and interfaces (finalized in Java 17) let a type declare exactly which other types are allowed to extend or implement it. Instead of an open hierarchy that anyone can subclass, you write a closed set the compiler can reason about. That single guarantee — these and only these — is what powers exhaustive pattern matching and makes data-shaped class hierarchies safe to model.
A sealed type is the natural partner of records. Records give you the data; sealing gives you the closed set of cases. Together they bring algebraic data types (the "sum type" you may know from Kotlin, Rust, or Scala) to plain Java, and they change how a switch over a hierarchy behaves.
Sealing a Type with permits
A type becomes sealed with the sealed modifier and a permits clause that lists every direct subtype. No other class can join the hierarchy, even in the same package. The permitted subtypes must be accessible to the sealed type and, in the unnamed module, live in the same package (or the same module).
public sealed interface Payment
permits Cash, Card, BankTransfer {}
public record Cash(int amount) implements Payment {}
public record Card(String number, int amount) implements Payment {}
public record BankTransfer(String iban, int amount) implements Payment {}If a subtype sits in the same source file, the permits clause is optional — the compiler infers it from the file. You only need to spell permits out explicitly when the subtypes live in separate files.
// Same file: permits is inferred, so it can be omitted.
sealed interface Expr {
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
}The final, sealed, and non-sealed Rule
Every permitted subtype must itself say how its part of the hierarchy is closed. The compiler forces a choice: each direct subtype must be declared final, sealed, or non-sealed. There is no "do nothing" option — leaving the modifier off is a compile error.
| Modifier | Meaning for the subtype |
|---|---|
final | The subtype cannot be extended further. Records are implicitly final. |
sealed | The subtype is itself closed and supplies its own permits list. |
non-sealed | The subtype reopens the hierarchy — anyone may extend it again. |
public sealed class Shape permits Circle, Polygon, Freeform {}
public final class Circle extends Shape {} // closed here
public sealed class Polygon extends Shape // closed, but to a set
permits Triangle, Rectangle {}
public non-sealed class Freeform extends Shape {} // reopened: any subclass allowed
public final class Triangle extends Polygon {}
public final class Rectangle extends Polygon {}non-sealed is the escape hatch: it lets one branch of an otherwise closed hierarchy stay open for extension. Use it sparingly, because it forfeits the exhaustiveness guarantee for that branch.
Why Sealing Enables Exhaustive Switch
The payoff for closing a hierarchy is that the compiler knows the complete list of cases. A switch over a sealed type that covers every permitted subtype is exhaustive, so you do not write a default branch. Better still, if someone later adds a new permitted subtype, every non-exhaustive switch stops compiling — the compiler points you at the code that forgot the new case.
sealed interface Payment permits Cash, Card, BankTransfer {}
record Cash(int amount) implements Payment {}
record Card(String number, int amount) implements Payment {}
record BankTransfer(String iban, int amount) implements Payment {}
static String fee(Payment p) {
return switch (p) { // no default needed
case Cash c -> "no fee";
case Card c -> "2% card fee";
case BankTransfer b -> "flat fee";
};
}Drop the BankTransfer case and the code will not compile: "the switch expression does not cover all possible input values." That compile-time push is the central reason to seal a hierarchy.
Records, Deconstruction, and Guards
Because permitted subtypes are usually records, you can combine sealing with record deconstruction patterns and guarded patterns (when). Deconstruction binds the record's components directly in the case label; a guard adds a boolean condition. Ordering matters: more specific guarded cases must come before the unguarded fallback for the same type.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static String describe(Shape s) {
return switch (s) {
case Circle(double r) when r > 10 -> "big circle";
case Circle(double r) -> "circle r=" + r;
case Rectangle(double w, double h) when w == h -> "square";
case Rectangle(double w, double h) -> "rectangle";
};
}The compiler still treats this as exhaustive: every permitted subtype is matched by at least one unguarded label, so the whole switch is total even though some labels are guarded.
A Worked Example
The runnable example below ties everything together: a sealed Shape interface with three record subtypes, an exhaustive switch for area, a guarded deconstruction switch for description, and a peek at the sealing metadata through reflection. It uses only the JDK, so it runs as-is.
What to take from the run:
- The area
switchhas nodefaultbranch — becauseShapeis sealed, covering all three records is already exhaustive. describeprintsbig circle r=12.0only for the radius-12 circle, proving thewhen r > 10guard is tried before the unguardedCirclelabel.- The radius-5-each rectangle prints
square side=5.0, showing thew == hguard wins over the plainRectanglecase that follows it. - The total area (525.96) is accumulated across every record subtype, confirming one polymorphic loop handles the whole closed hierarchy.
Shape.class.isSealed()returnstrueandgetPermittedSubclasses()lists Circle, Rectangle, and Triangle — thepermitsset survives into runtime metadata.
Practice
Why can an exhaustive switch over a sealed interface omit the default branch?