Java Sealed Class Pattern Matching Explained
java sealed class pattern matching: Learn how to combine sealed classes with pattern matching in Java for exhaustive, compile-time-checked switch expressions and safer...
Sealed classes and pattern matching are two Java features that work together to make type hierarchies more expressive and safer. When you combine them, the compiler can verify that every possible subtype is handled in a switch expression, eliminating a whole class of runtime errors. This article explains how java sealed class pattern matching works, how to use it effectively, and what to watch out for in production code.
Why Sealed Classes Matter for Pattern Matching
Before sealed classes, a developer could not restrict which classes could extend a base class or implement an interface. Any class in the module could extend it, so the compiler could never prove that a switch over all known subtypes was exhaustive. Pattern matching for switch requires exhaustiveness to give you compile-time safety. Without sealed types, you always needed a default branch or a default case to handle unknown subtypes.
Sealed classes change that. By declaring a class or interface as sealed, you list the permitted subclasses. The compiler now knows the complete set of possible types. This knowledge is exactly what pattern matching needs to verify that a switch expression covers all cases. The result is that you can write code that is both concise and provably safe.
Declaring a Sealed Hierarchy
The syntax for a sealed class or interface is straightforward. You use the sealed modifier and the permits clause to list the allowed subclasses. For example:
public sealed interface Shape permits Circle, Rectangle, Triangle { double area(); } public record Circle(double radius) implements Shape { @Override public double area() { return Math.PI * radius * radius; } } public record Rectangle(double width, double height) implements Shape { @Override public double area() { return width * height; } } public record Triangle(double base, double height) implements Shape { @Override public double area() { return 0.5 * base * height; } }
All permitted subclasses must be in the same module or package (unless they are in the same compilation unit). They must also be either final, sealed, or non-sealed. In this example, the records are implicitly final. This constraint ensures the hierarchy is fully known at compile time.
Using Pattern Matching with Sealed Types
The real power comes when you use pattern matching in a switch expression. With Java 21, pattern matching for switch is a standard feature. You can match on the type and extract components directly:
public String describe(Shape shape) { return switch (shape) { case Circle c -> "Circle with radius " + c.radius(); case Rectangle r -> "Rectangle " + r.width() + "x" + r.height(); case Triangle t -> "Triangle with base " + t.base(); }; }
Because Shape is sealed, the compiler knows there are only three possible subtypes. The switch expression is exhaustive without a default branch. If you add a new permitted subtype later, the compiler will flag every switch that does not handle it. This is a major improvement over traditional instanceof chains where missing a case is a silent runtime bug.
Exhaustiveness and the Compiler's Role
The compiler enforces exhaustiveness only when the switch uses pattern matching and the selector expression's type is sealed. If you use a default branch, it is still allowed, but the compiler no longer requires it. However, removing the default forces you to handle every case explicitly. This is often desirable because it makes the code self-documenting and prevents accidental fallthrough.
Consider what happens if you add a new shape, say Pentagon. The compiler will produce an error in the describe method: "the switch expression does not cover all possible input values." You are forced to update the switch. This compile-time check is the main reason developers adopt sealed classes for pattern matching. It shifts the burden of correctness from runtime testing to the compiler.
Combining Sealed Classes with Records
Records are a natural fit for sealed hierarchies because they are immutable and their components are accessible via accessor methods. Pattern matching can deconstruct records directly using record patterns. For example, you can write a method that computes the area without calling area():
public double area(Shape shape) { return switch (shape) { case Circle(double radius) -> Math.PI * radius * radius; case Rectangle(double width, double height) -> width * height; case Triangle(double base, double height) -> 0.5 * base * height; }; }
The record pattern Circle(double radius) binds the radius component to a local variable. This eliminates boilerplate getter calls and makes the logic clearer. When you combine sealed classes, records, and pattern matching, you get a concise, type-safe way to model algebraic data types, similar to what functional languages offer.
Runtime Behavior and Performance Considerations
Pattern matching for switch is compiled to efficient bytecode. The JVM uses type checks and, in some cases, a type switch table to dispatch quickly. The exact performance characteristics depend on the JVM implementation, but in practice, the overhead is comparable to an instanceof chain. The compiler may generate a tableswitch or lookupswitch based on the type's hash or ordinal, but you should not rely on micro-optimizations without profiling.
One operational consideration is that sealed classes do not add any runtime cost by themselves. The permits clause is enforced at compile time; the JVM does not check it at runtime (except during class loading for verification). This means you can use sealed classes freely without worrying about reflection or serialization overhead. However, if you use reflection to instantiate classes, you must ensure that the class is a permitted subtype; otherwise, an IncompatibleClassChangeError may occur.
Common Pitfalls and Compatibility Notes
Sealed classes require Java 17 or later. Pattern matching for switch requires Java 21 or later. If you are on an older version, you can use pattern matching for instanceof (since Java 16) with sealed classes, but you lose the exhaustiveness check for switch. You can still achieve a similar effect with if-else chains, but the compiler cannot verify that all cases are covered.
Another pitfall is the placement of the permits clause. The permitted subclasses must be listed in the same compilation unit if they are not in the same package or module. If you forget to list a subclass that is in the same package, you will get a compile error. Also, the permitted subclasses must be accessible from the sealed class; otherwise, the compiler will reject the declaration.
When using sealed classes with serialization, be aware that the serialized form includes the class name. Adding a new permitted subtype is a compatible change for serialization, but removing one is not. This is a design consideration for long-lived APIs.
Finally, remember that sealed classes are not a replacement for enums. Enums are for a fixed set of constants, while sealed classes allow you to have different behavior and state per subtype. Use sealed classes when you need a closed hierarchy with varying data and behavior, and use enums when you simply need a set of named values.
By combining sealed classes with pattern matching, you get compile-time exhaustiveness, clearer code, and a more maintainable type hierarchy. The compiler becomes your ally in ensuring that every possible case is handled, which is especially valuable in large codebases where a missed subtype can cause subtle production failures.