Java Sealed Classes & Interfaces (Java 17+)

Sealed classes and interfaces, made standard in Java 17 (JEP 409), let you explicitly control which classes can extend or implement a given type. This gives the compiler a complete picture of a type hierarchy, enabling exhaustive switch expressions and safer domain modeling that was previously impossible without enums.

Contents

Syntax: sealed, permits, final, non-sealed

A sealed class uses the sealed modifier and a permits clause listing its allowed subclasses. Each permitted subclass must itself be declared as one of: final (no further subclassing), sealed (extends the sealed hierarchy), or non-sealed (reopens the hierarchy for arbitrary extension).

Java
// Sealed class with three permitted subclasses
public sealed class Shape permits Circle, Rectangle, Triangle {}

// final — no further subclassing allowed
public final class Circle extends Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    public double radius() { return radius; }
}

// final subclass
public final class Rectangle extends Shape {
    private final double width, height;
    public Rectangle(double width, double height) {
        this.width = width; this.height = height;
    }
    public double width()  { return width; }
    public double height() { return height; }
}

// non-sealed — allows anyone to extend Triangle
public non-sealed class Triangle extends Shape {
    private final double base, height;
    public Triangle(double base, double height) {
        this.base = base; this.height = height;
    }
    public double base()   { return base; }
    public double height() { return height; }
}

// Anyone can now extend Triangle (but NOT Shape directly)
public class EquilateralTriangle extends Triangle {
    public EquilateralTriangle(double side) { super(side, side * Math.sqrt(3) / 2); }
}
Sealed classes and their permitted subclasses must be in the same package (or module). If in the same source file, the permits clause can be omitted: the compiler infers it.

Sealed Interfaces

Sealed interfaces work the same way. Permitted implementors can be final classes, sealed classes, non-sealed classes, or even records (records are implicitly final).

Java
public sealed interface Expr
    permits Num, Add, Mul, Neg, Var {}

public record Num(double value)        implements Expr {}
public record Add(Expr left, Expr right) implements Expr {}
public record Mul(Expr left, Expr right) implements Expr {}
public record Neg(Expr expr)           implements Expr {}
public record Var(String name)         implements Expr {}

// Sealed interface with mixed implementors
public sealed interface Result<T>
    permits Result.Ok, Result.Err {

    record Ok<T>(T value)    implements Result<T> {}
    record Err<T>(String msg) implements Result<T> {}

    static <T> Result<T> ok(T value) { return new Ok<>(value); }
    static <T> Result<T> err(String msg) { return new Err<>(msg); }
}

Exhaustive switch with Sealed Types

The biggest advantage of sealed types is that the compiler knows the complete set of subtypes at compile time. A pattern-matching switch over a sealed type is checked for exhaustiveness, if you miss a case, you get a compile-time error, not a runtime exception.

Java
static double area(Shape shape) {
    // No default needed — compiler verifies all cases are covered
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        // Triangle is non-sealed, so its subtypes don't add new cases here
    };
}

// Recursive expression evaluator — no default case needed
static double eval(Expr expr, Map<String, Double> env) {
    return switch (expr) {
        case Num(var v)        -> v;
        case Var(var name)     -> env.getOrDefault(name, 0.0);
        case Neg(var e)        -> -eval(e, env);
        case Add(var l, var r) -> eval(l, env) + eval(r, env);
        case Mul(var l, var r) -> eval(l, env) * eval(r, env);
    };
}

// Usage
Map<String, Double> env = Map.of("x", 3.0, "y", 4.0);
Expr expr = new Add(new Mul(new Var("x"), new Var("x")),
                    new Mul(new Var("y"), new Var("y")));
System.out.println(eval(expr, env)); // 25.0  (x²+y²)
If you add a new permitted subtype to a sealed hierarchy, every exhaustive switch over that type will immediately fail to compile, forcing you to handle the new case explicitly. This is the key safety guarantee.

Domain Modeling Example

Sealed types shine for modeling domain concepts that have a fixed set of variants: similar to algebraic data types (sum types) in functional languages.

Java
// Payment method hierarchy — every variant is known
public sealed interface PaymentMethod
    permits CreditCard, BankTransfer, Crypto {}

public record CreditCard(String number, String holder, int cvv)
    implements PaymentMethod {}

public record BankTransfer(String iban, String bic)
    implements PaymentMethod {}

public record Crypto(String walletAddress, String coin)
    implements PaymentMethod {}

// Processing logic — exhaustive, no instanceof chains
static String processPayment(PaymentMethod method, double amount) {
    return switch (method) {
        case CreditCard(var num, var holder, var cvv) ->
            "Charging %.2f to card ending %s for %s"
                .formatted(amount, num.substring(num.length() - 4), holder);

        case BankTransfer(var iban, var bic) ->
            "Transferring %.2f to IBAN %s (BIC: %s)"
                .formatted(amount, iban, bic);

        case Crypto(var wallet, var coin) ->
            "Sending %.2f worth of %s to %s"
                .formatted(amount, coin, wallet);
    };
}

Rules and Constraints

  • All permitted subclasses must be in the same package (or same module for named modules).
  • Every permitted subclass must directly extend/implement the sealed type, not through an intermediate class.
  • A permitted subclass must be marked final, sealed, or non-sealed.
  • Sealed classes can be abstract.
  • Records and enums are implicitly final, so they naturally work as permitted subclasses.
  • You cannot declare a sealed class non-sealed: those are opposites.
Java
// Enums work naturally as sealed-type implementors
public sealed interface Status permits Status.Active, Status.Inactive, Status.Pending {
    enum Active    implements Status { INSTANCE }
    enum Inactive  implements Status { INSTANCE }
    enum Pending   implements Status { INSTANCE }
}

// Or use a real enum — it is implicitly sealed
public enum OrderStatus { PLACED, CONFIRMED, SHIPPED, DELIVERED, CANCELLED }
// Exhaustive switch over enum:
static String label(OrderStatus s) {
    return switch (s) {
        case PLACED    -> "Order placed";
        case CONFIRMED -> "Confirmed";
        case SHIPPED   -> "On the way";
        case DELIVERED -> "Delivered";
        case CANCELLED -> "Cancelled";
    };
}