W3docs

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.

ModifierMeaning for the subtype
finalThe subtype cannot be extended further. Records are implicitly final.
sealedThe subtype is itself closed and supplies its own permits list.
non-sealedThe 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.

java— editable, runs on the server

What to take from the run:

  • The area switch has no default branch — because Shape is sealed, covering all three records is already exhaustive.
  • describe prints big circle r=12.0 only for the radius-12 circle, proving the when r > 10 guard is tried before the unguarded Circle label.
  • The radius-5-each rectangle prints square side=5.0, showing the w == h guard wins over the plain Rectangle case 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() returns true and getPermittedSubclasses() lists Circle, Rectangle, and Triangle — the permits set survives into runtime metadata.

Practice

Practice

Why can an exhaustive switch over a sealed interface omit the default branch?