Java permits Clause: Sealing Class Hierarchies
java permits clause: Learn how the Java permits clause restricts subclass hierarchies in sealed classes and interfaces, with syntax, constraints, and maintainability t...
The java permits clause is part of sealed classes and interfaces, a feature finalized in Java 17. It lets you declare exactly which classes or interfaces may extend or implement a given type. This turns an open inheritance model into a closed one, giving the compiler enough information to reason about all possible subtypes.
The Role of the permits Clause in Sealed Types
A sealed class or interface restricts its subclasses or implementors to a fixed set. The permits clause lists those allowed types explicitly. Without it, any class in the same module or package could extend the type, making it impossible for the compiler to know all subtypes at compile time.
Consider a simple domain model for a payment system. You want to allow only CreditCard, BankTransfer, and PayPal as payment methods. Sealing the PaymentMethod interface guarantees that no other implementation can exist outside the permitted set.
public sealed interface PaymentMethod permits CreditCard, BankTransfer, PayPal { double amount(); }
Here, permits is the clause that names the three allowed implementations. Any attempt to create a fourth implementing class directly will fail at compile time unless that class is added to the permits list.
Syntax and Placement of permits in a Sealed Class Declaration
The permits clause appears after the class or interface name and before the class body. It must list every direct subclass or implementor that is allowed. The order of the names does not matter, but each name must be accessible and in the same module as the sealed type.
public sealed class Shape permits Circle, Rectangle, Triangle { // common shape logic } public final class Circle extends Shape { // circle-specific implementation } public non-sealed class Rectangle extends Shape { // rectangle-specific implementation } public final class Triangle extends Shape { // triangle-specific implementation }
Each permitted subclass must be declared with one of three modifiers: final, sealed, or non-sealed. A final class ends the hierarchy for that branch. A sealed class continues the restriction with its own permits clause. A non-sealed class opens the hierarchy again, allowing unknown subclasses.
If a permitted subclass is not declared in the same source file as the sealed type, it must be listed in the permits clause. If it is in the same file, the compiler can infer it, and the permits clause becomes optional for that subclass.
Constraints on Permitted Subclasses: Location and Modifiers
The permits clause imposes strict rules on where permitted subclasses can live. In Java 17, each permitted subclass must be in the same module as the sealed class. If the sealed class is in the unnamed module, the subclasses must be in the same package. This constraint prevents the hierarchy from spanning unrelated parts of the codebase.
Another constraint is that a permitted subclass cannot be anonymous or local. It must be a named top-level or nested class. This keeps the hierarchy explicit and inspectable.
The modifiers on permitted subclasses also matter. A final subclass cannot be extended further. A sealed subclass must itself declare a permits clause for its own subclasses. A non-sealed subclass can be extended by anyone, which reintroduces open inheritance for that branch.
public sealed class Vehicle permits Car, Truck { } public sealed class Car extends Vehicle permits Sedan, SUV { } public final class Sedan extends Car { } public final class SUV extends Car { } public non-sealed class Truck extends Vehicle { }
In this example, Car is sealed and restricts its own subclasses. Truck is non-sealed, so any class can extend it. This gives you fine-grained control over how much of the hierarchy remains closed.
How the Compiler Enforces Exhaustiveness in Switch Expressions
One of the main benefits of the permits clause is that the compiler can verify that a switch expression over a sealed type is exhaustive. When you switch on a sealed interface, the compiler knows all permitted implementations and can require a case for each one.
public String describe(PaymentMethod method) { return switch (method) { case CreditCard c -> "Credit card payment of " + c.amount(); case BankTransfer b -> "Bank transfer of " + b.amount(); case PayPal p -> "PayPal payment of " + p.amount(); }; }
If you omit one of the permitted types, the compiler reports an error because the switch is not exhaustive. This moves a whole class of runtime MatchException or default-case mistakes to compile time. Without the permits clause, the compiler would have no way to know whether the switch covers all possible subtypes.
This exhaustiveness check works with both switch statements and switch expressions. It also works with pattern matching for instanceof when used in a sealed context. The closed nature of the hierarchy is what makes these checks possible.
permits vs. no permits: When the Clause Is Optional
The permits clause is not always required. If all permitted subclasses are declared in the same source file as the sealed type, the compiler can infer them. In that case, you can omit the clause entirely.
public sealed class Expression { } final class Literal extends Expression { } final class BinaryOp extends Expression { } final class UnaryOp extends Expression { }
Here, Expression is sealed, and its three subclasses are in the same file. The compiler knows them without an explicit permits clause. This reduces verbosity when the hierarchy is small and self-contained.
However, if any subclass is in a different file, the permits clause becomes mandatory. The compiler must know exactly which types are allowed, and it cannot scan the entire package or module for subclasses. This is a deliberate design choice to keep the contract explicit and to avoid accidental subclassing.
When you use the permits clause, every listed subclass must be directly related. You cannot list a subclass that is not actually declared to extend the sealed type. The compiler checks that each permitted type is a direct subclass or implementor.
Maintaining a Sealed Hierarchy: Adding and Removing Permitted Types
The permits clause affects how you evolve a class hierarchy. Adding a new permitted type requires changing the sealed class declaration and the permits clause. This is a source-compatible change for existing code, but it may break exhaustive switches that do not handle the new type.
Removing a permitted type is more disruptive. If you delete a subclass from the permits clause, any code that references that subclass will fail to compile. Also, the subclass itself must be removed or made non-sealed if it is no longer permitted. This makes the hierarchy explicit but also more rigid.
For library authors, this rigidity is often desirable. It prevents consumers from extending internal hierarchies in unintended ways. It also makes API evolution predictable because the set of subtypes is part of the public contract.
A common maintenance pattern is to use non-sealed for a branch that you expect to grow. For example, a Shape hierarchy might have a Polygon subclass that is non-sealed, allowing library users to create custom polygon types. The rest of the hierarchy remains sealed.
Compatibility and Reflection: What Changes at Runtime
Sealed classes do not introduce new runtime bytecode instructions. The restriction is enforced entirely at compile time. At runtime, a sealed class behaves like a regular class, and the permits clause is recorded as metadata in the class file.
This metadata is accessible through reflection. The Class class has a method getPermittedSubclasses() that returns an array of Class<?> objects representing the permitted subclasses. This allows frameworks and tools to inspect the hierarchy at runtime.
Class<?> shapeClass = Shape.class; Class<?>[] permitted = shapeClass.getPermittedSubclasses(); for (Class<?> subclass : permitted) { System.out.println(subclass.getName()); }
If a class is not sealed, getPermittedSubclasses() returns null. This is a useful way to detect whether a type is sealed without relying on isSealed().
The isSealed() method, added in Java 17, returns true if the class or interface is sealed. Combined with getPermittedSubclasses(), you can build generic utilities that handle sealed hierarchies dynamically.
One important runtime consideration is serialization. If a sealed class implements Serializable, the permitted subclasses must also be serializable if they are to be serialized. The permits clause does not automatically make subclasses serializable. You still need to manage serialization explicitly.
Another compatibility note: sealed classes are fully backward compatible with existing bytecode. Code compiled before Java 17 that extends a sealed class will still load, but it will throw an IncompatibleClassChangeError when the sealed class is initialized. This is a deliberate safety mechanism to prevent the sealed contract from being violated at runtime.