Skip to main content
Java intermediate Lesson 34 of 58

Sealed Classes and Interfaces in Java

Master sealed classes and interfaces (Java 17+) — restrict class hierarchies, use permits, and combine with pattern matching for exhaustive switching.

Why Sealed Classes?

Before sealed classes, Java had no way to restrict which classes could extend a superclass. You could use final to prevent all extension, or leave a class open to anyone. Sealed classes give you a third option: a known, finite set of permitted subtypes.

// Without sealed — anyone can extend Shape
public abstract class Shape { ... }
// Someone in another package:
public class Triangle extends Shape { ... } // always allowed

// With sealed — only the listed types are permitted
public sealed class Shape permits Circle, Rectangle, Triangle { ... }
// Outside the permits list: compile error
// public class Hexagon extends Shape { ... } // ERROR

Declaring Sealed Classes and Interfaces

// Sealed abstract class
public sealed abstract class Vehicle
    permits Car, Truck, Motorcycle {}

// Each permitted subtype must be final, sealed, or non-sealed
public final class Car extends Vehicle {
    private final int doors;
    public Car(int doors) { this.doors = doors; }
    public int doors() { return doors; }
}

public final class Truck extends Vehicle {
    private final double payloadTons;
    public Truck(double payloadTons) { this.payloadTons = payloadTons; }
    public double payloadTons() { return payloadTons; }
}

// non-sealed — opens this branch to unrestricted extension
public non-sealed class Motorcycle extends Vehicle {
    private final boolean hasSidecar;
    public Motorcycle(boolean hasSidecar) { this.hasSidecar = hasSidecar; }
    public boolean hasSidecar() { return hasSidecar; }
}

// Now anyone can extend Motorcycle (the seal is broken for this branch)
public class Scooter extends Motorcycle {
    public Scooter() { super(false); }
}

Sealed Interfaces with Records

Sealed interfaces pair naturally with records to create algebraic data types (sum types):

// A payment can only be one of three types
public sealed interface Payment
    permits Payment.CreditCard, Payment.BankTransfer, Payment.Crypto {

    record CreditCard(String cardNumber, String cvv, double amount)
        implements Payment {}

    record BankTransfer(String iban, String bic, double amount)
        implements Payment {}

    record Crypto(String walletAddress, String currency, double amount)
        implements Payment {}
}

Pattern Matching with Sealed Classes (Java 21+)

The real power of sealed classes is exhaustive switch expressions — the compiler knows all possible subtypes and warns when a case is missing:

static double processFee(Payment payment) {
    return switch (payment) {
        case Payment.CreditCard  cc -> cc.amount() * 0.025;    // 2.5% fee
        case Payment.BankTransfer bt -> bt.amount() * 0.005;   // 0.5% fee
        case Payment.Crypto      c  -> c.amount() * 0.01;      // 1.0% fee
        // No default needed — all subtypes are covered
        // Compiler error if a new type is added to permits and not handled here
    };
}

static String describePayment(Payment payment) {
    return switch (payment) {
        case Payment.CreditCard cc ->
            "Card ending in " + cc.cardNumber().substring(cc.cardNumber().length() - 4)
            + " for $" + cc.amount();
        case Payment.BankTransfer bt ->
            "Bank transfer from " + bt.iban() + " for $" + bt.amount();
        case Payment.Crypto c ->
            c.amount() + " " + c.currency() + " to " + c.walletAddress();
    };
}

// Usage
Payment p = new Payment.CreditCard("4111111111111234", "123", 99.99);
System.out.println(processFee(p));       // 2.4997...
System.out.println(describePayment(p));  // Card ending in 1234 for $99.99

Modelling Domain State

Sealed interfaces are excellent for modelling domain state machines:

public sealed interface OrderStatus
    permits OrderStatus.Pending, OrderStatus.Processing,
            OrderStatus.Shipped, OrderStatus.Delivered, OrderStatus.Cancelled {

    record Pending(java.time.Instant placedAt)
        implements OrderStatus {}

    record Processing(java.time.Instant startedAt, String warehouseId)
        implements OrderStatus {}

    record Shipped(java.time.Instant shippedAt, String trackingNumber)
        implements OrderStatus {}

    record Delivered(java.time.Instant deliveredAt)
        implements OrderStatus {}

    record Cancelled(java.time.Instant cancelledAt, String reason)
        implements OrderStatus {}
}

public class Order {
    private final String id;
    private OrderStatus status;

    public Order(String id) {
        this.id = id;
        this.status = new OrderStatus.Pending(java.time.Instant.now());
    }

    public void process(String warehouseId) {
        if (status instanceof OrderStatus.Pending) {
            status = new OrderStatus.Processing(java.time.Instant.now(), warehouseId);
        } else {
            throw new IllegalStateException("Cannot process order in status: " + status);
        }
    }

    public String statusSummary() {
        return switch (status) {
            case OrderStatus.Pending p      -> "Waiting since " + p.placedAt();
            case OrderStatus.Processing pr  -> "Processing at " + pr.warehouseId();
            case OrderStatus.Shipped s      -> "Tracking: " + s.trackingNumber();
            case OrderStatus.Delivered d    -> "Delivered at " + d.deliveredAt();
            case OrderStatus.Cancelled c    -> "Cancelled: " + c.reason();
        };
    }
}

Expression Trees (Sealed + Recursion)

A classic use case — modelling an abstract syntax tree:

public sealed interface Expr
    permits Expr.Num, Expr.Add, Expr.Mul, Expr.Neg {

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

public class Evaluator {

    public static double eval(Expr expr) {
        return switch (expr) {
            case Expr.Num n   -> n.value();
            case Expr.Add a   -> eval(a.left()) + eval(a.right());
            case Expr.Mul m   -> eval(m.left()) * eval(m.right());
            case Expr.Neg neg -> -eval(neg.expr());
        };
    }

    public static String prettyPrint(Expr expr) {
        return switch (expr) {
            case Expr.Num n   -> String.valueOf(n.value());
            case Expr.Add a   -> "(" + prettyPrint(a.left()) + " + " + prettyPrint(a.right()) + ")";
            case Expr.Mul m   -> "(" + prettyPrint(m.left()) + " * " + prettyPrint(m.right()) + ")";
            case Expr.Neg neg -> "-" + prettyPrint(neg.expr());
        };
    }

    public static void main(String[] args) {
        // (3 + 4) * -2
        Expr e = new Expr.Mul(
            new Expr.Add(new Expr.Num(3), new Expr.Num(4)),
            new Expr.Neg(new Expr.Num(2))
        );

        System.out.println(prettyPrint(e)); // ((3.0 + 4.0) * -2.0)
        System.out.println(eval(e));        // -14.0
    }
}

Guarded Patterns (Java 21)

Pattern matching in switch can include when guards for additional conditions:

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius)             implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height)   implements Shape {}

static String classify(Shape shape) {
    return switch (shape) {
        case Circle c when c.radius() > 10  -> "Large circle";
        case Circle c                        -> "Small circle";
        case Rectangle r when r.width() == r.height() -> "Square";
        case Rectangle r                     -> "Rectangle";
        case Triangle t                      -> "Triangle";
    };
}

System.out.println(classify(new Circle(15)));      // Large circle
System.out.println(classify(new Circle(5)));       // Small circle
System.out.println(classify(new Rectangle(4, 4))); // Square
System.out.println(classify(new Rectangle(3, 5))); // Rectangle

When to Use Sealed Classes

SituationRecommendation
Fixed set of subtypes that won’t change externallySealed class/interface
Algebraic data types (Result, Option, Either)Sealed interface + records
State machine with known statesSealed interface + records
Expression trees / ASTsSealed interface + records
Open extension points / plugin architectureRegular interface
Single class, no subclassing neededfinal class

Frequently Asked Questions

What is the difference between a sealed class and a final class?
A final class cannot be extended by anyone. A sealed class can only be extended by the classes listed in its permits clause, which you control. Sealed classes let you build a closed, known set of subtypes while still allowing extension within that set.
Do all permitted subclasses have to be in the same file?
No, but they must be in the same package (or module). If they are in the same source file as the sealed class, the permits clause is optional — the compiler infers it. Otherwise you must list them explicitly.
What are the options for permitted subclasses?
Each permitted subclass must be declared final (no further extension), sealed (extends the hierarchy further with its own permits), or non-sealed (open to unrestricted extension — 'opens a hole' in the seal).
Why use sealed classes over a marker interface?
Sealed classes give the compiler a complete, exhaustive list of subtypes. This enables exhaustive pattern matching in switch expressions — the compiler warns if you miss a case. Marker interfaces cannot provide this guarantee.