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
| Situation | Recommendation |
|---|---|
| Fixed set of subtypes that won’t change externally | Sealed class/interface |
| Algebraic data types (Result, Option, Either) | Sealed interface + records |
| State machine with known states | Sealed interface + records |
| Expression trees / ASTs | Sealed interface + records |
| Open extension points / plugin architecture | Regular interface |
| Single class, no subclassing needed | final class |