Back to Blog
Java

Using Java Sealed Interfaces for Exhaustive Pattern Matching

Java sealed interfaces restrict implementations and enable compiler-checked exhaustive switch expressions. See how to declare them, compare them with sealed classes, and understand when to use them.

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

A sealed interface in Java restricts which other interfaces or classes may directly implement or extend it. This is a compile-time and class-loading contract that makes a type hierarchy explicit and lets the compiler verify exhaustive pattern matching in switch expressions. Sealed interfaces are standard in Java 17. Pattern matching for switch was a preview feature in Java 17 and became final in Java 21, so the switch examples in this article require the corresponding language level (on Java 17, compile with --enable-preview). 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 direct subtype must be marked final, sealed, or non-sealed for classes; a permitted subinterface uses sealed or non-sealed. If the sealed interface is in a named module, every permitted subtype must be in the same module; if it is in the unnamed module, every permitted subtype must be in the same package. For example:

public final class Circle implements Shape { private final double radius; public Circle(double radius) { this.radius = radius; } public double radius() { return 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 abstract sealed class Triangle implements Shape permits EquilateralTriangle { // EquilateralTriangle must implement area(). }

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 are useful with pattern matching for switch. Since the compiler knows the 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 checking does not apply to if chains that use instanceof patterns; the compiler enforces exhaustiveness only for switch expressions and pattern-labeled switch statements.

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 avoid limiting the type to a single superclass.

AspectSealed InterfaceSealed Class
StateNo instance fieldsCan have instance fields
Supertype flexibilityA class can implement the interface and also implement other interfacesA class has one direct superclass, but it can also implement other interfaces
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, you can declare a permitted subtype as non-sealed, but that branch is then open and the compiler can no longer enumerate its further subclasses.

Another constraint is module placement. In a named module, every permitted subtype must be in the same module; in the unnamed module, every permitted subtype must be in the same package. 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 during ordinary method dispatch. The sealed constraints are checked during compilation and class loading; they are not re-evaluated as part of each method call. Reflection can 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. The permits list is part of the class metadata, and the sealed type is rechecked when a class is loaded. If you later remove or rename a permitted subtype, previously serialized instances of that subtype can become impossible to deserialize in a JVM that enforces the new hierarchy. This is a compatibility risk when evolving sealed hierarchies, so consider whether the sealed set is truly permanent before you seal it.

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 rejects the code with a clear error, and the fix is simple. Another common issue is getting the placement rules wrong: in the unnamed module, a permitted subtype must be in the same package as the sealed interface, while in a named module it must be in the same module but may be in a different package.

A more subtle issue arises when a subtype is declared non-sealed and then extended further. A switch over the sealed interface can still use that subtype's type pattern to cover its subclasses, but the compiler can no longer know the complete list of subclasses under that branch. If you need the compiler to track every leaf of the hierarchy, avoid non-sealed, or include a default case and accept reduced exhaustiveness.

Finally, when you add a new permitted subtype, every exhaustive switch must be updated. 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 permitted 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 code with exhaustive switches must be updated. In a library, this can break downstream consumers: code compiled against the old hierarchy may not have a case for the new subtype and can fail at runtime if the new subtype reaches the switch. To mitigate this, consider whether the set of subtypes is truly closed. If you anticipate future additions, a non-sealed subtype can act as an escape hatch, but it opens that branch and reduces the compiler's ability to enumerate subtypes. 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 Java sealed interfaces 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: Declaration, Pattern Matching, and Design Tradeoffs | RYUSLOG DEV