Contents
- Syntax: sealed, permits, final, non-sealed
- Sealed Interfaces
- Exhaustive switch with Sealed Types
- Domain Modeling Example
- Rules and Constraints
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).
// 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 work the same way. Permitted implementors can be final classes, sealed classes, non-sealed classes, or even records (records are implicitly final).
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); }
}
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.
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.
Sealed types shine for modeling domain concepts that have a fixed set of variants — similar to algebraic data types (sum types) in functional languages.
// 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);
};
}
- 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.
// 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";
};
}