What are sealed classes in Java, and what problem do they solve?
A sealed class or interface is one whose set of permitted subclasses is fixed and written down in the source. You declare it with the sealed modifier and list the permitted subtypes with the permits clause.
The problem it solves is exhaustiveness. Before sealed types, if you modelled a payment method as an interface with four implementations, the compiler had no idea whether a switch over it was complete — add a fifth implementation and every switch in the codebase silently falls through to its default branch. With a sealed interface, the compiler knows the complete set of subtypes and can check every switch against it.
public class Main {
public static void main(String[] args) {
Shape circle = new Circle(2);
Shape square = new Square(3);
Shape triangle = new Triangle(4, 5);
for (Shape shape : new Shape[]{circle, square, triangle}) {
System.out.println(describe(shape));
}
// The hierarchy is closed: only these three implementations exist,
// so the compiler can prove the branches below are exhaustive.
System.out.println("implementations : " + Shape.implementations().size());
}
static String describe(Shape shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return "circle of radius " + c.radius();
}
if (shape instanceof Square) {
Square s = (Square) shape;
return "square of side " + s.side();
}
if (shape instanceof Triangle) {
Triangle t = (Triangle) shape;
return "triangle " + t.base() + "x" + t.height();
}
return "unknown";
}
}
// A closed hierarchy, spelled the pre-sealed way: an interface plus the
// exact set of implementations the code is allowed to see.
interface Shape {
static java.util.List<Class<? extends Shape>> implementations() {
return java.util.List.of(Circle.class, Square.class, Triangle.class);
}
}
final class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
double radius() {
return radius;
}
}
final class Square implements Shape {
private final double side;
Square(double side) {
this.side = side;
}
double side() {
return side;
}
}
final class Triangle implements Shape {
private final double base;
private final double height;
Triangle(double base, double height) {
this.base = base;
this.height = height;
}
double base() {
return base;
}
double height() {
return height;
}
}The sample above shows that hierarchy written the pre-sealed way, so you can run it: one interface and exactly three implementations. The sealed keyword does not change how the code is dispatched at runtime, it changes what the compiler knows about the set of subtypes, and each implementation is final, which is what makes the hierarchy genuinely closed.
The rule about what a permitted subtype must be is the one to remember. Every direct subclass of a sealed type must be declared final, sealed or non-sealed. A record is implicitly final, so it satisfies the rule with no extra keyword. The non-sealed keyword is the escape hatch: it reopens the hierarchy below that point, so that type can be extended by classes outside the sealed set.
Sealing also brings a run-time benefit. Because the permitted set is known, the JVM can implement a pattern-matching switch more efficiently than a chain of instanceof tests, and it makes it possible to use a switch expression over a sealed type without a default branch.