Unlocking Control: A Deep Dive into Java's Sealed Classes
Java, a language known for its robust and opinionated design, continues to evolve, bringing features that empower developers with greater control and expressiveness. One such significant addition, finalized in Java 17 (after a preview in Java 15 and 16), is Sealed Classes.
If you've ever found yourself wishing for a way to explicitly define which classes can extend your superclass, or which interfaces can implement your parent interface, then sealed classes are the answer you've been waiting for. They introduce a level of controlled extensibility that was previously challenging to achieve, opening up new possibilities for designing cleaner, safer, and more maintainable code.
The Problem Sealed Classes Solve
Before sealed classes, controlling inheritance was a rather blunt instrument:
final
classes: You could declare a classfinal
, preventing any further extension. This is great for truly unextendable types, but what if you wanted a specific, limited set of subtypes?- Package-private constructors/classes: You could restrict extension to within the same package. While this offers some control, it also limits the visibility and usability of your superclass, potentially forcing awkward architectural decisions.
Consider a scenario where you're building a system that deals with different types of geometric Shape
s. You know you'll have Circle
, Rectangle
, and Triangle
, and you want to ensure that only these specific shapes can extend your Shape
class. Without sealed classes, another developer could unwittingly (or intentionally) introduce a Pentagon
class extending Shape
, even if your business logic isn't prepared for it. This can lead to unexpected bugs and brittle code.
Sealed classes provide the elegant solution to this very problem.
What are Sealed Classes?
At its core, a sealed class (or interface) explicitly permits a defined set of other classes (or interfaces) to extend or implement it. Any attempt by an unpermitted class to extend a sealed type will result in a compile-time error.
This means you can declare a closed universe of subtypes, giving you compile-time guarantees about your inheritance hierarchies.
How to Declare a Sealed Type
Declaring a sealed class or interface is straightforward, involving two key elements: the sealed
keyword and the permits
clause.
// A sealed class named 'Shape'
public sealed class Shape permits Circle, Rectangle, Triangle {
// Common properties and abstract methods for all shapes
public abstract double calculateArea();
public abstract String getType();
}
sealed
: This new contextual keyword signifies that the classShape
is sealed.permits Circle, Rectangle, Triangle
: This crucial clause explicitly lists the classes that are allowed to directly extendShape
. Any other class attempting to extendShape
will trigger a compile-time error.
The Rules for Permitted Subclasses
The permitted subclasses themselves are not left unregulated. They must declare how they can be extended, ensuring complete control over the hierarchy:
- Direct Extension/Implementation: Each permitted class must directly extend the sealed class (or implement the sealed interface).
- Location: The sealed class and its permitted subclasses must reside in the same Java module. If they are in an unnamed module (the default for most smaller projects), they must be in the same package. This constraint allows the Java compiler to verify the entire sealed hierarchy at compile time.
- Modifier for Permitted Subclasses: Each permitted subclass must declare one of the following modifiers:
final
: The subclass cannot be extended further. This creates a leaf node in your inheritance tree.sealed
: The subclass itself is also sealed, meaning it will have its ownpermits
clause, controlling its direct subclasses. This allows for multi-level sealed hierarchies.non-sealed
: The subclass can be extended by any other class, effectively "opening up" the inheritance hierarchy from that point onwards. Use this when you want to permit a specific type, but then allow unconstrained extension from there.
A Practical Example: The Shape Hierarchy Revisited
Let's expand on our Shape
example to illustrate these concepts:
// Shape.java
public sealed abstract class Shape permits Circle, Rectangle, Triangle {
public abstract double calculateArea();
public abstract String getType();
}
// Circle.java
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;
}
@Override
public String getType() {
return "Circle";
}
}
// Rectangle.java
public non-sealed class Rectangle extends Shape {
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;
}
@Override
public String getType() {
return "Rectangle";
}
}
// Square.java - Can extend Rectangle because Rectangle is non-sealed
public final class Square extends Rectangle {
public Square(double side) {
super(side, side);
}
@Override
public String getType() {
return "Square";
}
}
// Triangle.java
public sealed class Triangle extends Shape permits EquilateralTriangle, IsoscelesTriangle {
protected final double base;
protected final double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
@Override
public String getType() {
return "Triangle";
}
}
// EquilateralTriangle.java
public final class EquilateralTriangle extends Triangle {
private final double side;
public EquilateralTriangle(double side) {
super(side, (Math.sqrt(3) / 2) * side); // Example calculation for height
this.side = side;
}
@Override
public String getType() {
return "Equilateral Triangle";
}
}
// IsoscelesTriangle.java
public final class IsoscelesTriangle extends Triangle {
private final double sideA;
private final double sideB;
private final double baseLength;
public IsoscelesTriangle(double base, double height, double sideA, double sideB, double baseLength) {
super(base, height);
this.sideA = sideA;
this.sideB = sideB;
this.baseLength = baseLength;
}
@Override
public String getType() {
return "Isosceles Triangle";
}
}
// Invalid extension attempt (compile-time error!)
// public class Pentagon extends Shape { /* ... */ }
In this example:
Circle
isfinal
, meaning it cannot be extended further.Rectangle
isnon-sealed
, allowingSquare
to extend it without explicit permission inRectangle
'spermits
clause.Triangle
issealed
itself, meaning onlyEquilateralTriangle
andIsoscelesTriangle
can extend it.
The Power of Exhaustive switch
Expressions
One of the most compelling synergies of sealed classes is with Pattern Matching for switch
expressions (also finalized in Java 17). Because the compiler knows the exact, finite set of permitted subclasses of a sealed type, it can perform exhaustiveness checking for switch
expressions.
This means you can write switch
expressions that are guaranteed to cover all possible subtypes, without needing a default
case if all permitted types are explicitly handled.
public class ShapeProcessor {
public static String describeShape(Shape shape) {
return switch (shape) {
case Circle c -> "This is a circle with radius " + c.radius(); // Assuming record for Circle, or access field directly
case Rectangle r -> "This is a rectangle with dimensions " + r.width() + "x" + r.height();
case Triangle t -> "This is a triangle."; // Could be further pattern matched if Triangle was sealed and had distinct sub-types
// No default needed! Compiler ensures all permitted subtypes are covered.
};
}
}
If you were to add a new permitted subclass to Shape
(e.g., Pentagon
) but forget to update the switch
expression, the compiler would immediately flag it as a non-exhaustive switch
, forcing you to handle the new type. This dramatically reduces the chances of runtime errors due to unhandled cases.
Benefits of Sealed Classes
- Enhanced Type Safety: Enforces strict control over inheritance, preventing unintended extensions and leading to more robust designs.
- Improved Code Clarity and Maintainability: The
permits
clause acts as self-documenting code, immediately conveying the intended hierarchy. This makes code easier to read, understand, and evolve. - Compile-Time Guarantees: Errors related to unpermitted extensions are caught at compile time, saving debugging headaches later.
- Optimized
switch
Expressions: Enables exhaustiveswitch
expressions, eliminating the need fordefault
cases when all permitted subtypes are covered, and providing compile-time warnings for missing cases. - Domain Modeling: Ideal for modeling real-world domains where a type has a fixed and known set of variations (e.g., different states of an order, types of user roles, specific command types in a command pattern).
When to Use Sealed Classes
Consider using sealed classes when:
- You have an abstract type (class or interface) with a well-defined, finite, and unchanging set of direct implementors or extenders.
- You want to prevent external code from creating new, unknown subtypes of your base type.
- You want to leverage exhaustive
switch
expressions for pattern matching over instances of your base type. - You're designing APIs where the set of possible return types or input types should be strictly controlled.
Conclusion
Sealed classes are a significant addition to the Java language, providing a powerful and elegant solution for controlled inheritance. By allowing developers to explicitly define and limit the types that can extend or implement a class or interface, they contribute to more robust, readable, and maintainable codebases. Coupled with pattern matching for switch
expressions, sealed types unlock new levels of type safety and expressiveness, making Java an even more compelling choice for building complex and reliable applications. If you haven't explored them yet, now is the perfect time to integrate sealed classes into your Java development workflow.