Sealed Classes and Interfaces in Java: A Comprehensive Guide
Before Java 15, developers faced a binary choice when controlling class/interface extension: either allow unlimited subclassing (open types) or forbid it entirely (final types). Sealed types introduced in Java 15 (preview) and standardized in Java 17 fill this gap by letting you explicitly define which classes or interfaces can extend or implement a type.
This feature is transformative for algebraic data types (ADTs), pattern matching, and domain modeling, as it enables type-safe exhaustive checks and controlled extension. In this guide, we’ll dive deep into sealed types, their syntax, use cases, best practices, and integration with modern Java features like pattern matching.
Table of Contents#
- Why Sealed Types?
- Syntax & Basic Usage
- 2.1 Sealed Classes
- 2.2 Sealed Interfaces
- Permitted Types: Rules & Constraints
- Pattern Matching with Sealed Types
- Common Use Cases
- Best Practices
- Pitfalls to Avoid
- Migration from Traditional Approaches
- Conclusion
- References
1. Why Sealed Types?#
Sealed types solve three key problems with traditional Java extension models:
- Uncontrolled Extension: Open types allow any subclass, leading to unexpected behavior in APIs.
- Over-restriction: Final types forbid all extension, which is inflexible for domain modeling.
- Lack of Exhaustiveness: Without sealed types, pattern matching (e.g., switch statements) can miss cases, leading to runtime errors.
By explicitly listing permitted subtypes, sealed types enable:
- Type-safe exhaustive checks via the compiler.
- Clearer domain modeling (e.g., defining all possible states of an order).
- Flexible extension (allowing some subtypes to be open further with
non-sealed).
2. Syntax & Basic Usage#
Sealed types use the sealed modifier followed by a permits clause listing allowed subtypes. Subtypes must declare their own extension policy with final, sealed, or non-sealed.
2.1 Sealed Classes#
A sealed class restricts which classes can extend it. Here’s an example for geometric shapes:
// Sealed class: Permits only Circle, Rectangle, Triangle
public sealed class Shape permits Circle, Rectangle, Triangle {
public abstract double calculateArea();
}
// Final subclass: No further extension allowed
public final class Circle extends Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// Sealed subclass: Allows only Square as a subtype
public sealed class Rectangle extends Shape permits Square {
protected final double width;
protected final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
// Non-sealed subclass: Allows unrestricted extension
public non-sealed class Triangle extends Shape {
private final double base;
private final double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
// Final subclass of Rectangle
public final class Square extends Rectangle {
public Square(double side) {
super(side, side);
}
}2.2 Sealed Interfaces#
Sealed interfaces work similarly but restrict which classes can implement them. Example for payment methods:
// Sealed interface: Permits only CreditCard, DebitCard, PayPal
public sealed interface Payment permits CreditCardPayment, DebitCardPayment, PayPalPayment {
boolean process(double amount);
}
public final class CreditCardPayment implements Payment {
private final String cardNumber;
public CreditCardPayment(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public boolean process(double amount) {
return amount > 0;
}
}
public final class DebitCardPayment implements Payment {
private final String cardNumber;
public DebitCardPayment(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public boolean process(double amount) {
return amount > 0;
}
}
public non-sealed class PayPalPayment implements Payment {
private final String email;
public PayPalPayment(String email) {
this.email = email;
}
@Override
public boolean process(double amount) {
// Simulate PayPal processing
return amount > 0;
}
}3. Permitted Types: Rules & Constraints#
The permits clause has strict rules to maintain type safety:
- Location:
- For non-modular projects: Permitted types must be in the same package as the sealed type.
- For modular projects (Java 17+): Permitted types can be in any package within the same module.
- Modifiers: Each permitted type must explicitly declare one of:
final: No further extension allowed.sealed: Restricts extension to its ownpermitslist.non-sealed: Allows unrestricted extension (breaks the closed hierarchy for that subtype).
- Inheritance: Permitted types must directly extend/implement the sealed type (no indirect inheritance).
4. Pattern Matching with Sealed Types#
Sealed types shine when combined with pattern matching, as the compiler can enforce exhaustive coverage of all subtypes.
4.1 Exhaustive Switch Expressions#
Java 14+ (standardized in Java 17) allows switch expressions that cover all sealed subtypes without needing a default case:
public double sumAreas(List<Shape> shapes) {
return shapes.stream()
.map(shape -> switch (shape) {
case Circle c -> c.calculateArea();
case Rectangle r -> r.calculateArea();
case Triangle t -> t.calculateArea();
// No default needed: Compiler ensures all subtypes are covered
})
.reduce(0.0, Double::sum);
}If you add a new subtype to Shape, the compiler will throw an error until you update the switch expression.
4.2 Pattern Matching with Sealed Types#
Java 16+ allows pattern matching with instanceof, eliminating the need for explicit casting. When combined with sealed types, using switch pattern matching can enforce exhaustive checking of all subtypes:
public void printShapeDetails(Shape shape) {
if (shape instanceof Circle c) {
System.out.printf("Circle with radius: %.2f%n", c.radius);
} else if (shape instanceof Rectangle r) {
System.out.printf("Rectangle with dimensions: %.2fx%.2f%n", r.width, r.height);
} else if (shape instanceof Triangle t) {
System.out.printf("Triangle with base: %.2f and height: %.2f%n", t.base, t.height);
}
// Compiler can enforce exhaustive checking when using switch pattern matching
}5. Common Use Cases#
5.1 Algebraic Data Types (ADTs)#
Sealed types are ideal for modeling ADTs like sums (e.g., success/failure results):
public sealed class Result<T> permits Success, Error {
private Result() {}
public abstract <U> Result<U> map(Function<T, U> mapper);
public final class Success extends Result<T> {
private final T value;
public Success(T value) {
this.value = value;
}
@Override
public <U> Result<U> map(Function<T, U> mapper) {
return new Success<>(mapper.apply(value));
}
public T getValue() { return value; }
}
public final class Error extends Result<Object> {
private final String message;
public Error(String message) {
this.message = message;
}
@Override
public <U> Result<U> map(Function<Object, U> mapper) {
return new Error<>(this.message);
}
public String getMessage() { return message; }
}
}5.2 Domain State Machines#
Model state transitions for domain objects (e.g., order states):
public sealed class OrderState permits Created, Paid, Shipped, Delivered {
public abstract OrderState transition();
}
public final class Created extends OrderState {
@Override
public OrderState transition() {
return new Paid();
}
}
public final class Paid extends OrderState {
@Override
public OrderState transition() {
return new Shipped();
}
}
public final class Shipped extends OrderState {
@Override
public OrderState transition() {
return new Delivered();
}
}
public final class Delivered extends OrderState {
@Override
public OrderState transition() {
return this;
}
}5.3 Controlled API Design#
Use sealed interfaces to restrict implementations of public APIs, ensuring consistency:
// Public sealed interface: Only internal classes can implement it
public sealed interface DatabaseConnection permits MySQLConnection, PostgreSQLConnection {
void connect();
void disconnect();
}
// Internal implementation classes
final class MySQLConnection implements DatabaseConnection { ... }
final class PostgreSQLConnection implements DatabaseConnection { ... }6. Best Practices#
- Use Sealed Types for Closed Hierarchies: Only use sealed types when you can enumerate all possible subtypes upfront (e.g., state machines, ADTs).
- Prefer
finalfor Subtypes: Most permitted subtypes should befinalto avoid unintended extension. - Use
non-sealedSparingly: Only usenon-sealedwhen you need to allow third-party extension of a specific subtype. - Leverage Exhaustive Checks: Always use pattern matching with sealed types to enforce exhaustive coverage.
- Document Hierarchies: Even though the code declares permitted subtypes, document the hierarchy in API docs for clarity.
- Combine with Records: Use Java 16+ records for immutable subtypes to reduce boilerplate (e.g.,
public record Circle(double radius) extends Shape { ... }).
7. Pitfalls to Avoid#
- Ignoring Package/Module Rules: For non-modular projects, permitted subtypes must be in the same package as the sealed type.
- Forgetting Modifiers: Permitted subtypes must declare
final,sealed, ornon-sealed(compiler error if missing). - Adding
defaultto Switch Expressions: This defeats exhaustive checking and can lead to missing cases when the hierarchy changes. - Overcomplicating Hierarchies: Don’t use sealed types for simple classes where extension isn’t a concern.
- Breaking Encapsulation: Avoid exposing internal state of subtypes (use getters instead of public fields).
8. Migration from Traditional Approaches#
From Package-Private Abstract Classes#
Instead of using package-private constructors to restrict extension:
// Old approach: Package-private constructor
public abstract class Shape {
Shape() {} // Restricts extension to same package
}
// New approach: Sealed class
public sealed class Shape permits Circle, Rectangle, Triangle { ... }From Enum Types#
Enums are singletons, but sealed types allow stateful variants:
// Old approach: Enum (no state per variant)
public enum ShapeType { CIRCLE, RECTANGLE }
// New approach: Sealed class with stateful subtypes
public sealed class Shape permits Circle, Rectangle {
public abstract double calculateArea();
}9. Conclusion#
Sealed classes and interfaces are a powerful addition to Java, enabling safer domain modeling, algebraic data types, and exhaustive pattern matching. By controlling extension and leveraging compiler-enforced checks, they reduce runtime errors and improve code readability. When used correctly, they transform how you design APIs and model complex domains in Java.