Sealed classes and interfaces (since Java 17) restrict which classes or interfaces may extend or implement them. This enables exhaustive pattern matching, safer API design, and clearer domain modeling when the set of variants is fixed and known at compile time.

Why Sealed Classes?

Before sealed classes, you could either make a class final (no extension at all) or leave it open (anyone can subclass). Sealed classes occupy the middle ground: controlled extension — only explicitly permitted types can participate in the hierarchy.

Benefits:

  • Exhaustive pattern matching — the compiler verifies all cases are handled
  • Domain modeling — express fixed variant sets (shapes, payment methods, AST nodes)
  • API stability — third parties cannot add unexpected subclasses

Sealed Class Syntax

  public sealed class Shape
        permits Circle, Rectangle, Triangle {
    // common behavior or abstract methods
    public abstract double area();
}

public final class Circle extends Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    public double radius() { return radius; }
    @Override public double area() { return Math.PI * radius * radius; }
}

public final class Rectangle extends Shape {
    private final double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    @Override public double area() { return width * height; }
}

public non-sealed class Triangle extends Shape {
    private final double base, height;
    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
    @Override public double area() { return 0.5 * base * height; }
}
  

Every permitted subclass must appear in the permits clause (or be in the same file/module where permits can be omitted).

Permitted Subclass Modifiers

Every permitted subclass must be one of:

Modifier Meaning
final Cannot be extended further
sealed Must declare its own permits clause
non-sealed Open for further extension by anyone
  public sealed interface Expression
        permits Literal, BinaryOp, Grouping { }

public final class Literal implements Expression { /* ... */ }
public sealed class BinaryOp implements Expression
        permits Add, Subtract { }
public final class Add extends BinaryOp { /* ... */ }
public final class Subtract extends BinaryOp { /* ... */ }
public non-sealed class Grouping implements Expression { /* ... */ }
  

Sealed Interfaces

Interfaces can also be sealed — common with records as implementations:

  public sealed interface Payment
        permits CreditCard, BankTransfer, Crypto { }

public record CreditCard(String number, String cvv) implements Payment { }
public record BankTransfer(String iban) implements Payment { }
public record Crypto(String walletAddress) implements Payment { }
  

Records pair naturally with sealed interfaces — each variant is a concise, immutable data carrier.

Pattern Matching with Sealed Types

Sealed hierarchies enable exhaustive switch expressions (Java 21+):

  public String describe(Payment payment) {
    return switch (payment) {
        case CreditCard c  -> "Card ending " + c.number().substring(c.number().length() - 4);
        case BankTransfer b -> "Transfer to " + b.iban();
        case Crypto c       -> "Wallet " + c.walletAddress();
    }; // No default needed — compiler verifies exhaustiveness
}
  

If you add a new permitted subclass, the compiler flags every non-exhaustive switch — a compile-time safety net.

Before Pattern Matching (instanceof chains)

  // Old style — easy to miss a case
public double area(Shape shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.radius() * c.radius();
    } else if (shape instanceof Rectangle r) {
        return r.width() * r.height();
    } else if (shape instanceof Triangle t) {
        return 0.5 * t.base() * t.height();
    }
    throw new IllegalArgumentException("Unknown shape");
}
  

Sealed Classes vs Final vs Abstract

Feature final abstract sealed
Can be extended No Yes (by anyone) Yes (only permitted types)
Can be instantiated Yes No (directly) Depends on subclass
Exhaustive switch N/A No Yes
Use case Prevent all extension Template method pattern Fixed variant set

Real-World Use Cases

Result Type

  public sealed interface Result<T> permits Success, Failure {
    record Success<T>(T value) implements Result<T> { }
    record Failure<T>(String error, int code) implements Result<T> { }
}

public <T> T unwrap(Result<T> result) {
    return switch (result) {
        case Success<T>(var value) -> value;
        case Failure<T>(var error, var code) ->
            throw new RuntimeException("Error " + code + ": " + error);
    };
}
  

AST Nodes in a Compiler

  public sealed interface AstNode permits LiteralNode, BinaryNode, UnaryNode { }
public record LiteralNode(int value) implements AstNode { }
public record BinaryNode(AstNode left, String op, AstNode right) implements AstNode { }
public record UnaryNode(String op, AstNode operand) implements AstNode { }
  

Module System Interaction

In Java modules, sealed classes can restrict permits to types within the same module or specific packages. Subclasses in another module must be explicitly listed in permits and exported.

Best Practices

  • Use sealed types when the set of variants is fixed and known at design time
  • Combine with records for concise, immutable variant definitions
  • Prefer final permitted subclasses unless further extension is intentional
  • Use exhaustive switch instead of instanceof chains
  • Document why a hierarchy is sealed — future maintainers need context

Troubleshooting

Compile Error Cause Fix
“class X must extend sealed class” Missing from permits Add class to permits clause
“sealed permitted subclass must be final, sealed, or non-sealed” Missing modifier on subclass Add final, sealed, or non-sealed
“switch expression not exhaustive” New subclass added Add new case to all switches
Permits in different module Module boundary restriction Export package or co-locate classes

Sealed classes bring algebraic data types to Java — controlled inheritance hierarchies with compile-time exhaustiveness checking, especially powerful when combined with records and pattern matching.