Using Java Sealed Classes to Control Inheritance
java sealed class: Learn how Java sealed classes restrict inheritance, enable exhaustive pattern matching, and improve API design with practical examples.
A java sealed class restricts which other classes or interfaces may extend or implement it. Introduced in Java 17, the feature gives you explicit control over your type hierarchy at compile time. Instead of relying on final to block all inheritance or leaving every class open, you define a fixed set of permitted subtypes. The compiler then enforces that no other class can extend the sealed type, which makes the full set of possible subtypes known statically.
What Sealed Classes Solve
Without sealed classes, a base class is either final (no subclasses at all) or open to any subclass. That binary choice often forces a tradeoff. If you want to allow a few known implementations, you have no way to express that directly. You can use package-private constructors or reflection checks, but those are runtime workarounds, not compile-time guarantees.
Sealed classes fill that gap. You declare a class or interface as sealed, list the permitted subclasses with permits, and the compiler verifies that every permitted subclass is in the same module or package. This makes the hierarchy explicit and auditable. For example, a domain model that represents a payment method can restrict itself to CreditCard, PayPal, and BankTransfer. No other implementation can appear, which simplifies validation, serialization, and pattern matching.
Declaring a Sealed Class
The syntax is straightforward. The sealed class declares its permitted subclasses after the permits keyword. Each permitted subclass must directly extend the sealed class and must be final, sealed, or non-sealed.
public sealed class PaymentMethod permits CreditCard, PayPal, BankTransfer { } public final class CreditCard extends PaymentMethod { } public final class PayPal extends PaymentMethod { } public final class BankTransfer extends PaymentMethod { }
The final modifier on each subclass means no further inheritance is allowed. If you need another level of hierarchy, you can declare a subclass as sealed and list its own permitted subtypes. Alternatively, non-sealed reopens the hierarchy, which is useful when you want to allow arbitrary subclasses below a specific point.
public sealed class Shape permits Circle, Rectangle, Polygon { } public final class Circle extends Shape { } public sealed class Polygon extends Shape permits Triangle, Hexagon { } public final class Triangle extends Polygon { } public final class Hexagon extends Polygon { }
Here Polygon is sealed and defines its own permitted subclasses. The compiler enforces that only Triangle and Hexagon can extend Polygon. This gives you a two-level hierarchy while still keeping the full set of concrete types finite.
Sealed Interfaces and Records
Sealed interfaces work the same way. A sealed interface can be implemented by classes, records, or other interfaces. This is especially useful for defining a closed set of operations or events.
public sealed interface Event permits OrderPlaced, PaymentReceived, OrderShipped { } public record OrderPlaced(long orderId, LocalDateTime timestamp) implements Event { } public record PaymentReceived(long orderId, BigDecimal amount) implements Event { } public record OrderShipped(long orderId, String trackingNumber) implements Event { }
Records pair naturally with sealed types because they are implicitly final. A record cannot be extended, so it satisfies the requirement that a permitted subclass be final. This combination gives you compact data carriers with a closed type hierarchy, which is ideal for event sourcing, command handling, and message processing.
Compile-Time Exhaustiveness with Pattern Matching
Sealed classes become significantly more useful when combined with pattern matching for switch expressions, available as a preview in Java 17 and finalized in Java 21. Because the compiler knows the complete set of permitted subtypes, it can verify that a switch covers all possible cases. If you add a new permitted subclass, every switch that uses the sealed type as a selector will fail to compile until you handle the new case.
public String describe(PaymentMethod method) { return switch (method) { case CreditCard cc -> "Card ending in " + cc.lastFour(); case PayPal pp -> "PayPal account " + pp.email(); case BankTransfer bt -> "Bank transfer to " + bt.accountNumber(); }; }
No default branch is needed because the compiler knows the three cases are exhaustive. If you later add a Cryptocurrency subclass to the sealed hierarchy, the switch will not compile until you add a corresponding case. This shifts a whole class of runtime errors to compile time and makes the code easier to maintain.
The same exhaustiveness applies to if chains when using pattern matching with instanceof. The compiler can warn or error if the chain does not cover all permitted types, depending on the language version and compiler flags.
Runtime Behavior and Reflection
Sealed classes do not add any runtime enforcement. The checks happen at compile time. At runtime, a sealed class is just an ordinary class with a special attribute in the class file that lists the permitted subclasses. Reflection can read this information using Class.getPermittedSubclasses(), which returns an array of Class objects.
Class<?>[] permitted = PaymentMethod.class.getPermittedSubclasses(); for (Class<?> clazz : permitted) { System.out.println(clazz.getName()); }
This can be useful for tools that need to discover all implementations, such as serialization frameworks or plugin systems. However, do not rely on reflection to enforce business rules. The compiler already guarantees the hierarchy, so reflection is only for metadata inspection.
One subtlety is that the permitted subclasses must be accessible at compile time. In a modular application, they must be in the same module. Without modules, they must be in the same package. This constraint keeps the hierarchy local and prevents external code from extending the sealed type.
Migration and Compatibility Considerations
Adding sealed to an existing class is a breaking change. Any existing subclass that is not listed in permits will no longer compile. Before converting a class to sealed, audit all known subclasses. If the hierarchy is already closed de facto, the migration is straightforward. If external code depends on extending the class, you cannot make it sealed without breaking that code.
A common migration path is to introduce a sealed interface and keep the existing class as a non-sealed implementation. For example, if you have an abstract class Animal with subclasses Dog and Cat, you could create a sealed interface Animal and make Dog and Cat implement it. This preserves the existing code while giving you the benefits of a closed hierarchy.
Another consideration is that sealed is not the same as final. A sealed class can have subclasses, but only the ones you specify. This distinction matters when designing APIs. If you want to allow a third-party library to extend a type, do not make it sealed. If you want to keep control over all implementations, sealed is the right choice.
Common Mistakes and Edge Cases
A frequent mistake is forgetting to mark a permitted subclass as final, sealed, or non-sealed. The compiler will reject the class with an error. Each permitted subclass must explicitly choose one of these three modifiers. This is not optional.
Another issue is placing the sealed class and its subclasses in different packages without a module. The compiler enforces that all permitted subclasses reside in the same package or module as the sealed class. If they are in separate packages, you must put them in a named module and use exports appropriately. This is a common source of confusion for developers who are new to the feature.
Records are implicitly final, so they satisfy the requirement without an explicit modifier. However, an enum cannot be a permitted subclass of a sealed class because enums are implicitly final but also have special semantics. The compiler will reject an enum as a permitted subclass. If you need a closed set of constants, an enum alone may be simpler than a sealed class.
Finally, be aware that sealed applies to direct subclasses only. If a permitted subclass is non-sealed, then its own subclasses are not restricted by the original sealed class. This is intentional, but it means the full set of concrete types may no longer be finite. Use non-sealed sparingly and only when you genuinely need to leave the hierarchy open at a lower level.
Sealed classes are a compile-time tool. They do not change the runtime behavior of your code, but they do make your intent explicit and catch errors early. When combined with pattern matching, they enable exhaustive handling that adapts automatically as you add new types. For API designers, sealed classes offer a way to publish a closed hierarchy without giving up the flexibility of polymorphism.