Back to Blog
Java

Using Java Sealed Interfaces for Exhaustive Pattern Matching

java sealed interface: Learn how to declare a Java sealed interface, restrict implementations, and use exhaustive switch expressions with pattern matching for safer code.

sealed interfacespattern matchingswitch expressionstype hierarchyJava 17
Illustration of a sealed envelope with a Java logo, representing a sealed interface restricting its permitted implementations.

A java sealed interface restricts which other interfaces or classes may implement it. This is a compile-time contract that makes type hierarchies explicit and enables exhaustive pattern matching in switch expressions. Before sealed types, a developer could not know all implementations of an interface, so the compiler could not verify that a switch covered every possible case. Sealed interfaces close that gap.

Declaring a Sealed Interface

To declare a sealed interface, use the sealed modifier and list the permitted subtypes with the permits clause:

public sealed interface Shape permits Circle, Rectangle, Triangle { double area(); }

Each permitted subtype must be in the same module or package as the sealed interface, and it must explicitly declare itself as final, sealed, or non-sealed. For example:

public final class Circle implements Shape { private final double radius; public Circle(double radius) { this.radius = radius; } @Override public double area() { return Math.PI * radius * radius; } } public non-sealed class Rectangle implements Shape { private final double width, height; public Rectangle(double width, double height) { this.width = width; this.height = height; } @Override public double area() { return width * height; } } public sealed class Triangle implements Shape permits EquilateralTriangle { // ... }

final prevents further extension, sealed continues the restriction, and non-sealed reopens the hierarchy. This gives you precise control over how far the type hierarchy can grow.

Exhaustive Switch with Pattern Matching

Sealed interfaces shine when combined with pattern matching for switch. Since the compiler knows all permitted subtypes, it can verify that a switch expression covers every case. For example:

public static String describe(Shape shape) { return switch (shape) { case Circle c -> "Circle with radius " + c.radius(); case Rectangle r -> "Rectangle with area " + r.area(); case Triangle t -> "Triangle with area " + t.area(); }; }

If you omit a case, the compiler reports an error because the switch is not exhaustive. This forces you to handle every subtype when you add a new one, preventing silent runtime misses. The same exhaustiveness applies to if chains using instanceof with pattern matching, though the compiler cannot enforce that as strictly outside a switch.

Sealed Interface vs. Sealed Class

The choice between a sealed interface and a sealed class depends on whether you need shared state. Interfaces cannot carry instance fields, so if your subtypes share common data, a sealed abstract class is more appropriate. Sealed interfaces are useful when you only need to define behavior contracts and want to allow a subtype to also implement other interfaces.

AspectSealed InterfaceSealed Class
StateNo instance fieldsCan have instance fields
Multiple inheritanceA subtype can implement other interfacesSingle inheritance only
Use casePure contractsShared implementation

For example, a sealed interface is a good fit for a JSON value type, where each subtype represents a different JSON node and there is no shared state beyond the type itself.

Design Considerations for Sealed Interfaces

Sealed interfaces work best when the set of subtypes is stable and known at compile time. They are a poor fit for extensible APIs where external clients must be able to add implementations. If you need to allow third-party extensions, use non-sealed on a subtype, but that reintroduces the exhaustiveness problem for that branch.

Another consideration is that every permitted subtype must be in the same module. In a multi-module project, you cannot spread subtypes across modules. This constraint is intentional: it keeps the hierarchy closed and auditable. If you need cross-module extensibility, sealed interfaces are not the right tool.

Runtime and Compatibility Implications

At runtime, sealed interfaces do not add significant overhead. The compiler enforces the restrictions; the JVM does not need to check permits during execution. Reflection can still inspect the permitted subtypes via Class.getPermittedSubclasses(), which returns an array of Class objects. This is useful for frameworks that need to discover all implementations dynamically.

Serialization is a subtle concern. If you serialize a subtype of a sealed interface, the permits list is part of the class metadata. If you later remove a subtype, deserializing an old serialized object will fail with an InvalidClassException. This is a compatibility risk when evolving sealed hierarchies. You must consider whether the sealed set is truly permanent or whether you might need to add or remove subtypes in future releases.

Common Mistakes and How to Avoid Them

One frequent mistake is forgetting to mark a permitted subtype as final, sealed, or non-sealed. The compiler will reject the code with a clear error, but the fix is simple. Another mistake is placing the sealed interface and its subtypes in different packages without proper imports; the permits clause requires the subtype to be accessible.

A more subtle issue arises when a subtype is declared non-sealed and then extended further. The compiler no longer knows the complete set of implementations, so exhaustiveness is lost for that branch. If you need exhaustiveness, avoid non-sealed unless you accept that the switch must include a default case.

Finally, when you add a new permitted subtype, you must update every exhaustive switch. The compiler will point out each switch that is no longer exhaustive, so this is not a silent failure. This is the main maintainability benefit: the compiler guides you through the change.

Evolving a Sealed Hierarchy Safely

Adding a new subtype is a breaking change for exhaustive switches. The compiler forces you to handle the new case, which is good, but it also means that any code that depends on the sealed interface must be recompiled. In a library, this can break downstream consumers if they have their own exhaustive switches. To mitigate this, consider whether the set of subtypes is truly closed. If you anticipate future additions, you might prefer a non-sealed subtype as an escape hatch, but then you lose exhaustiveness for that branch. The tradeoff is between compile-time safety and future flexibility.

Another evolution path is to convert a sealed interface into a sealed abstract class if you later need shared state. This is a source-incompatible change, so it should be done early in the design phase. Sealed interfaces are a deliberate commitment to a fixed type set, and that commitment should be part of your API design.

When Not to Use a Sealed Interface

Sealed interfaces are not a universal replacement for plain interfaces. If you have an interface that is implemented by many unrelated classes, or if you expect the implementation set to grow frequently, sealing it will create maintenance friction. For example, a logging interface that many third-party libraries implement should remain open. Sealed interfaces are most valuable in domains where the type hierarchy represents a closed set of variants, such as AST nodes, protocol messages, or configuration options.

In those cases, the combination of a java sealed interface with pattern matching gives you a clear, compiler-verified way to handle every variant. The cost is that you must think carefully about the future of your type hierarchy before you seal it.

java sealed interface: Practical Usage and Code Examples | RYUSLOG DEV