Java Sealed Keyword: Restricting Inheritance
Learn how Java's sealed keyword restricts class hierarchies, enables exhaustive switching, and improves maintainability in Java 17+.
The sealed keyword in Java controls which classes or interfaces can extend or implement a given type. Sealed classes and interfaces became a standard feature in Java 17. They give you a way to declare that a hierarchy is closed to further extension beyond a fixed set of permitted subtypes. This is not just a syntax nicety; it changes how the compiler can reason about exhaustiveness in pattern matching and switch expressions.
What the Sealed Keyword Restricts
In an unsealed class hierarchy, any class in the same package or module can extend a public class, and any interface can be implemented by any class. That openness is useful for frameworks and libraries, but it becomes a problem when you want to model a closed domain, such as a set of payment methods, AST nodes, or geometric shapes. Without a way to limit subtypes, the compiler cannot guarantee that a switch over all possible types is complete.
The sealed keyword addresses this by requiring you to declare the complete set of permitted subclasses at the point of declaration. The compiler then enforces that no other class can extend or implement the sealed type. This restriction is checked at compile time, so a violation results in a compilation error rather than a runtime surprise.
Declaring a Sealed Class or Interface
To declare a sealed class, you use the sealed modifier and a permits clause that lists the allowed subtypes. Each permitted subtype must be in the same module or, if in the unnamed module, the same package. Here is a minimal example:
public sealed class Shape permits Circle, Rectangle, Triangle { }
Each permitted subclass must then declare itself as final, sealed, or non-sealed. A final subclass ends the hierarchy. A sealed subclass continues the restriction with its own permitted subtypes. A non-sealed subclass opens the hierarchy again, allowing any further extension. This gives you fine-grained control over how far the restriction propagates.
public final class Circle extends Shape { } public sealed class Rectangle extends Shape permits Square { } public non-sealed class Triangle extends Shape { }
Sealed interfaces work the same way. A sealed interface lists its permitted implementations, and each implementation must be final, sealed, or non-sealed.
public sealed interface Operation permits Add, Subtract, Multiply { int apply(int a, int b); } public final class Add implements Operation { public int apply(int a, int b) { return a + b; } } // ... other implementations
Rules for Permitted Subclasses
If the permitted subtypes are declared outside the sealed type's own source file, the permits clause is required and must list every direct subtype. If all direct subtypes are declared in the same compilation unit as the sealed type, you can omit the clause and let the compiler infer it. The compiler verifies that the set of direct subtypes is complete and that each permitted subtype actually extends or implements the sealed type. A few rules govern this relationship:
- The permitted subclasses must be directly adjacent in the inheritance hierarchy. You cannot list a class that is not a direct child.
- The permitted subclasses must be accessible at the point of declaration. In practice, they must reside in the same module or package.
- Each permitted subclass must explicitly declare its own sealing status (
final,sealed, ornon-sealed). No default is assumed. - If a permitted subclass is
sealed, it must also have its ownpermitsclause unless its direct subtypes are in the same compilation unit, and the chain continues.
These rules ensure that the compiler can compute the complete set of direct permitted subtypes at compile time. That set is what enables exhaustive pattern matching.
Why Seal a Hierarchy: Exhaustive Pattern Matching
The most immediate benefit of sealed classes is the ability to write exhaustive switch expressions without a default case. With pattern matching for switch (standard in Java 21; preview in Java 17 through 20), the compiler can verify that the direct permitted subtypes are covered. If a new permitted subtype is added later, any exhaustive switch expression that does not handle it will fail to compile.
Consider a shape hierarchy with Circle, Rectangle, and Triangle. A method that calculates area can use a switch expression. The type patterns in switch are standard in Java 21; on a Java 17 through 20 JDK, this example requires --enable-preview:
public double area(Shape shape) { return switch (shape) { case Circle c -> Math.PI * c.radius() * c.radius(); case Rectangle r -> r.width() * r.height(); case Triangle t -> 0.5 * t.base() * t.height(); }; }
Because Shape is sealed, the compiler knows that Circle, Rectangle, and Triangle are its direct permitted subtypes. The Triangle type pattern also covers any subclasses of Triangle, so no default branch is needed, and the compiler will reject the switch if a direct permitted subtype case is missing. This moves a whole class of runtime errors to compile time.
Pattern matching in switch also benefits. Case labels can use type patterns, so you get a narrowed type in each branch without an explicit cast, and the compiler can verify that the set of cases is exhaustive for the sealed hierarchy.
Sealed vs. Final vs. Abstract
It is easy to confuse sealed classes with final or abstract classes, but they serve different purposes. A final class cannot be extended at all. An abstract class can be extended but places no limit on the number or identity of subclasses. A sealed class allows a fixed, explicitly enumerated set of subclasses.
| Modifier | Extension allowed? | Subclass list known at compile time? | Typical use case |
|---|---|---|---|
| final | No | N/A | Immutable value types, utility classes |
| abstract | Yes, unlimited | No | Base classes with shared behavior |
| sealed | Yes, only listed | Yes | Closed domain models, algebraic data types |
Sealed classes are often the right choice when you want the flexibility of an abstract base class but also want the compiler to enforce a closed set of variants. This is common in domain-driven design, where a business concept has a known set of categories.
Common Mistakes and How to Avoid Them
One frequent mistake is forgetting to list a subclass in the permits clause. If you define a class that extends a sealed class but is not listed, the compiler rejects it. The error message is clear, but it can be confusing when you are adding a new subtype. Remember to update the permits clause in the parent.
Another mistake is declaring a permitted subclass as neither final, sealed, nor non-sealed. The compiler will complain that the subclass must specify one of these modifiers. This is a deliberate design choice to force you to think about how far the hierarchy should extend.
A third issue arises when a sealed class is in a different package from its permitted subclasses. In the unnamed module, all permitted subclasses must be in the same package. If you try to split them across packages, the compiler will refuse. This is a real constraint, so plan your package structure accordingly.
Finally, do not use non-sealed casually. It reopens the hierarchy and defeats the purpose of sealing. Use it only when you have a specific reason to allow arbitrary extension from that point downward.
Runtime Behavior and Reflection
Sealedness is also represented at runtime. The Class object for a sealed type exposes its direct permitted subtypes via the getPermittedSubclasses() method. This method returns an array of Class objects; for a non-sealed class it returns an empty array.
Class<Shape> shapeClass = Shape.class; Class<?>[] permitted = shapeClass.getPermittedSubclasses();
This reflection API is useful for frameworks that need to discover the direct permitted subtypes of a sealed hierarchy at runtime, such as serialization libraries or dependency injection containers. However, note that the list is fixed at compile time; you cannot add a new subclass at runtime without recompiling.
Sealing is a language/API design guarantee rather than a runtime security mechanism. In ordinary code, the compiler prevents accidental violations. Do not rely on sealed types to protect secrets or defend against untrusted code.
When to Use Sealed Classes in Production Code
Sealed classes shine in scenarios where the domain has a fixed, known set of variants. Common examples include:
- AST nodes in a compiler or interpreter
- JSON value types (object, array, string, number, boolean, null)
- State machines with a finite set of states
- Command or event types in an event-sourcing system
In each case, the sealed hierarchy makes it impossible to add an unhandled permitted variant without the compiler noticing. This reduces the risk of IllegalStateException or missing branches in switch statements.
Sealed classes also work well with records. A record is implicitly final, so it can be a permitted subclass without extra effort. Combining sealed interfaces with records gives you a concise way to define algebraic data types in Java.
public sealed interface Result<T> permits Success, Failure { record Success<T>(T value) implements Result<T> {} record Failure<T>(String error) implements Result<T> {} }
Here, Result can only be a Success or a Failure, and both are records. This pattern is ideal for representing operations that can succeed or fail without using exceptions.
When you design a public API, consider whether sealing is appropriate. If you expect third-party developers to extend your types, sealing will prevent that. If you want to control the hierarchy for safety and maintainability, sealing is the right tool. Many library authors use sealed interfaces to prevent users from implementing the interface themselves, forcing them to use the provided implementations.
One operational advantage of sealed classes is that they make code easier to refactor. When you add a new permitted subtype, the compiler immediately points to every switch and pattern match that needs a new case. This is far better than discovering a missing case in production through a runtime error.
In summary, Java's sealed keyword is a powerful tool for expressing closed hierarchies. It gives you compile-time exhaustiveness, clearer domain models, and safer refactoring. Use it when you want to restrict inheritance to a known set of subtypes, and combine it with records and pattern matching for a modern, concise style of Java programming.