When I first encountered sealed classes in Java, it felt like discovering a missing piece of the language’s design. For years, we’ve modeled domains using inheritance, but always with a lingering concern: what if someone extends our classes in ways we never intended? Sealed classes finally give us the tools to express precise constraints in our type hierarchies.
Let me walk you through some practical techniques I’ve found invaluable when working with sealed classes. These approaches have helped me create more robust, self-documenting code that catches errors at compile time rather than runtime.
Starting with the basics, the declaration syntax is straightforward but powerful. You define a sealed class using the sealed
modifier and explicitly list its permitted subclasses. Each subclass must declare itself as either final, sealed, or non-sealed. This explicit permission system creates a contract that the compiler can verify and enforce.
public sealed class Shape permits Circle, Rectangle, Triangle { }
public final class Circle extends Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
public double radius() {
return radius;
}
}
public non-sealed class Rectangle extends Shape {
private final double width;
private final double height;
// Constructor and methods
}
public sealed class Triangle extends Shape permits Equilateral, Isosceles { }
What I love about this approach is how it combines flexibility with control. You can choose exactly where in your hierarchy to allow extension. Final classes prevent any further extension, sealed classes allow controlled extension, and non-sealed classes open up for unlimited extension from that point onward.
The real magic happens when you combine sealed classes with pattern matching. Before sealed classes, switch expressions over class hierarchies always needed a default case because the compiler couldn’t know all possible subtypes. Now, the compiler can verify that you’ve handled all permitted cases.
double area(Shape s) {
return switch(s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> {
// Complex triangle area calculation
yield calculateTriangleArea(t);
}
};
}
I’ve found this exhaustive checking incredibly valuable in production code. It eliminates whole categories of bugs where we might forget to handle a particular case. The compiler becomes your partner in ensuring completeness.
Sealed interfaces work just as well as sealed classes. I often use them when designing payment systems or other domains where I want to restrict possible implementations.
public sealed interface PaymentMethod permits CreditCard, PayPal, BankTransfer { }
public record CreditCard(String number, String expiryDate) implements PaymentMethod { }
public record PayPal(String email) implements PaymentMethod { }
public record BankTransfer(String accountNumber) implements PaymentMethod { }
The combination with records is particularly elegant. You get concise data carriers that are part of a controlled hierarchy. This pattern has become my go-to for representing algebraic data types in Java.
Nested sealed hierarchies allow for sophisticated type modeling. I recently used this approach when building a expression evaluator.
public sealed interface Expr permits Constant, Plus, Minus, Times {
sealed interface BinaryExpr extends Expr permits Plus, Minus, Times { }
record Constant(int value) implements Expr { }
record Plus(Expr left, Expr right) implements BinaryExpr { }
record Minus(Expr left, Expr right) implements BinaryExpr { }
record Times(Expr left, Expr right) implements BinaryExpr { }
}
This structure gives me both broad categorization (Expr) and specific subcategories (BinaryExpr) with compile-time safety. The compiler ensures that any BinaryExpr is either Plus, Minus, or Times.
Sometimes you need an escape hatch in your hierarchy. That’s where non-sealed classes come in handy. They allow you to mark a specific point where uncontrolled extension can begin.
public non-sealed class SpecialRectangle extends Rectangle {
// Special rectangle implementation
}
public class CustomRectangle extends SpecialRectangle {
// Now we can extend freely
}
I use this pattern when I want to allow extension but want to be explicit about where it happens. It’s much clearer than making the entire hierarchy open.
Generics work seamlessly with sealed classes. I often use this combination for type-safe result types.
public sealed interface Result<T> permits Success, Failure { }
public record Success<T>(T value) implements Result<T> { }
public record Failure<T>(String error) implements Result<T> { }
The compiler ensures that any code handling a Result must account for both success and failure cases. This eliminates the need for null checks and makes error handling explicit.
One of the most valuable aspects is compile-time hierarchy validation. The compiler actively prevents unauthorized extensions.
// This would cause a compile error:
// 'Square' is not allowed to extend sealed class 'Shape'
public class Square extends Shape { }
I can’t overstate how much time this has saved me. It catches inheritance mistakes during development rather than letting them surface as runtime issues.
In API design, sealed classes have become indispensable. They let me define clear response types that clients can handle exhaustively.
public sealed class ApiResponse permits SuccessResponse, ErrorResponse { }
public final class SuccessResponse extends ApiResponse {
private final Object data;
public SuccessResponse(Object data) {
this.data = data;
}
public Object getData() {
return data;
}
}
public final class ErrorResponse extends ApiResponse {
private final String errorCode;
private final String message;
// Constructor and getters
}
Client code can use pattern matching to handle all possible response types without worrying about unknown subtypes. This makes API consumption much safer.
The integration with records is particularly powerful for domain modeling. I often use this combination for tree structures and other recursive data types.
public sealed interface Node permits Leaf, Branch { }
public record Leaf(int value) implements Node { }
public record Branch(Node left, Node right) implements Node { }
This creates expressive, immutable data structures with built-in pattern matching support. The compiler ensures that any code working with Nodes handles both leaves and branches.
For framework development, the reflective access to permitted subclasses is incredibly useful.
Class<?>[] permitted = Shape.class.getPermittedSubclasses();
for (Class<?> subclass : permitted) {
System.out.println("Allowed subclass: " + subclass.getSimpleName());
}
I’ve used this in serialization frameworks and validation libraries to automatically discover and process all types in a sealed hierarchy. It enables dynamic behavior while maintaining the static safety guarantees.
What I appreciate most about sealed classes is how they encourage thoughtful design. You have to consciously decide where to allow extension and where to restrict it. This leads to more intentional, maintainable codebases.
The combination with pattern matching feels like a natural evolution of Java’s type system. It brings us closer to the expressiveness of functional languages while maintaining Java’s object-oriented roots and static type safety.
I’ve found that sealed classes work particularly well in domain-driven design. They allow you to model business constraints directly in the type system. When the domain experts say “there are exactly three types of payments,” you can encode that directly using a sealed interface.
The compile-time checks also make refactoring safer. When you add a new permitted subclass, the compiler immediately shows you all the places that need to be updated to handle the new case. This prevents the kind of partial updates that often lead to bugs.
In team environments, sealed classes serve as living documentation. The permits clause explicitly states what subclasses are expected and allowed. New team members can understand the intended design just by looking at the class declarations.
The performance benefits are worth mentioning too. Because the compiler knows all possible subtypes, it can optimize pattern matching and method dispatch. In performance-critical code, this can make a noticeable difference.
I’ve also found sealed classes valuable in testing. You can write exhaustive tests that cover all permitted subtypes, confident that you’re not missing any cases. This leads to more complete test coverage and higher confidence in the code.
The gradual adoption path is another strength. You can introduce sealed classes into existing codebases without breaking changes. Start by sealing a class and permitting its existing subclasses, then gradually refine the hierarchy as needed.
Error messages from the compiler are generally helpful too. When you forget to handle a case in a switch expression, the compiler tells you exactly which permitted subtypes are missing. This makes development smoother and more productive.
Looking forward, I’m excited to see how the ecosystem around sealed classes evolves. Libraries and frameworks are already starting to leverage them for safer, more expressive APIs. The pattern is becoming increasingly common in popular Java libraries.
In my own work, sealed classes have changed how I think about type design. I’m more likely to model domains using precise hierarchies rather than falling back to less specific designs. The language gives me the tools to express exactly what I mean, and that’s incredibly powerful.
The journey with sealed classes has been one of discovery and refinement. Each project brings new insights into how to best use them. They’ve become an essential part of my Java toolkit, and I can’t imagine going back to designing type hierarchies without them.
The beauty of sealed classes lies in their simplicity and power. They solve a real problem in elegant way, making our code safer and more expressive. It’s one of those features that, once you start using it, you wonder how you ever managed without it.