Java Sealed Class Inheritance: Syntax and Rules
java sealed class inheritance: Learn how to control inheritance with Java sealed classes, including permitted subtypes, rules, and integration with pattern matching.
Java sealed class inheritance restricts which classes can extend a given class or interface. Introduced as a final feature in Java 17, sealed classes give you explicit control over the subtype hierarchy, which is essential for writing exhaustive pattern matching and for modeling domain states that should not be extended arbitrarily. This article covers the syntax, rules, and practical implications of sealed class inheritance.
Declaring a Sealed Class and Its Permitted Subtypes
A sealed class is declared with the sealed modifier and a permits clause that lists every direct subtype allowed to extend it. For example:
public sealed class Shape permits Circle, Rectangle, Triangle { // common fields and methods }
Each permitted subtype must be declared in the same module (or the same package if the code is in the unnamed module). The compiler enforces that no other class can extend Shape unless it appears in the permits list. This is a compile-time guarantee, not a runtime check.
Every permitted subtype must itself be marked with one of three modifiers: final, sealed, or non-sealed. A final subclass ends the hierarchy for that branch. A sealed subclass continues to restrict its own subtypes. A non-sealed subclass reopens the hierarchy, allowing arbitrary subclasses.
public final class Circle extends Shape { } public sealed class Rectangle extends Shape permits Square { } public non-sealed class Triangle extends Shape { }
In this example, Circle cannot be extended further. Rectangle permits only Square. Triangle can be extended by any class, because non-sealed removes the restriction for that branch.
Rules That Govern Sealed Class Inheritance
Sealed class inheritance follows a few strict rules that you must understand to avoid compilation errors.
First, the permits clause must list every direct subtype. You cannot have a subclass that is not listed, and you cannot list a class that does not directly extend the sealed class. If a subclass is in a different package but the same module, it must still be listed explicitly.
Second, each permitted subtype must be in the same module. In a non-modular project, all classes are in the unnamed module, so they must be in the same package. This prevents the common mistake of trying to extend a sealed class from a different package without a module declaration.
Third, the sealed class and its permitted subtypes must be in the same compilation unit or, if separate files, they must be compiled together. The compiler needs to see the entire hierarchy to enforce the rules.
Fourth, a permitted subtype cannot be a primitive type, an array, or a generic type instantiation. It must be a named class, interface, or record.
Finally, the sealed class itself cannot be instantiated directly if it is abstract. If it is not abstract, it can be instantiated, but that is unusual because the point of sealing is to control the hierarchy.
Why Sealed Classes Enable Exhaustive Pattern Matching
The primary motivation for sealed class inheritance is to support exhaustive pattern matching in switch expressions and statements. When you switch over a sealed type, the compiler can verify that all possible subtypes are covered, so you do not need a default branch.
Consider this sealed hierarchy:
public sealed interface Expression permits Constant, Add, Multiply { int evaluate(); } public record Constant(int value) implements Expression { public int evaluate() { return value; } } public record Add(Expression left, Expression right) implements Expression { public int evaluate() { return left.evaluate() + right.evaluate(); } } public record Multiply(Expression left, Expression right) implements Expression { public int evaluate() { return left.evaluate() * right.evaluate(); } }
You can write a switch expression that handles every subtype:
public static int evaluate(Expression expr) { return switch (expr) { case Constant c -> c.value(); case Add a -> evaluate(a.left()) + evaluate(a.right()); case Multiply m -> evaluate(m.left()) * evaluate(m.right()); }; }
Because Expression is sealed, the compiler knows that Constant, Add, and Multiply are the only possible subtypes. If you later add a new permitted subtype and forget to update the switch, the code will not compile. This is a significant advantage over traditional polymorphic dispatch, where missing a case is only caught at runtime.
Sealed Interfaces and Records as Permitted Subtypes
Sealed interfaces work the same way as sealed classes. They allow you to restrict implementations of an interface, which is useful for defining closed sets of behavior.
public sealed interface PaymentMethod permits CreditCard, PayPal, BankTransfer { void process(); }
Records are natural fits for sealed hierarchies because they are implicitly final. A record cannot be extended, so it satisfies the final requirement for a permitted subtype without any extra modifier.
public record CreditCard(String number, String expiry) implements PaymentMethod { public void process() { /* ... */ } }
Using records with sealed interfaces gives you immutable data carriers that are easy to pattern-match. This combination is particularly effective in domain modeling, where you want to represent a fixed set of alternatives without writing boilerplate equals, hashCode, and toString methods.
Common Mistakes When Working with Sealed Inheritance
One frequent mistake is forgetting to mark a permitted subtype as final, sealed, or non-sealed. The compiler will reject the class with an error like "class is not allowed to extend sealed class" or "permitted class must be final, sealed, or non-sealed". Always check that every subclass in the permits list has one of these modifiers.
Another mistake is trying to extend a sealed class from a different package without a module declaration. In a non-modular project, all classes are in the same unnamed module, but they must be in the same package. If you need to spread subtypes across packages, you must place the sealed class and its subtypes in a named module and use the exports directive appropriately.
A third issue is omitting a subtype from the permits list. If you have a class that extends the sealed class but is not listed, compilation fails. Conversely, listing a class that does not directly extend the sealed class also fails. The compiler is strict about the exact set of direct subtypes.
Finally, developers sometimes try to use a sealed class as a type for a variable and then expect to add new subtypes later without modifying the sealed class. That defeats the purpose. Sealed hierarchies are meant to be closed; if you anticipate external extension, use a different design.
Sealed Classes in Modular Codebases and Production Considerations
Sealed classes interact with the Java module system. When a sealed class is exported from a module, all its permitted subtypes must be in the same module. This means you cannot have a library define a sealed class and then allow clients to add their own subtypes. That restriction is intentional: it keeps the hierarchy closed for security and maintainability.
From a production standpoint, sealed classes reduce the surface area for bugs. By restricting inheritance, you prevent unintended subclasses that might violate invariants. For example, a financial domain can model transaction types as a sealed class, ensuring that no external code can introduce a new transaction type that bypasses validation logic.
Migration from an abstract class to a sealed class requires careful planning. If you have an existing abstract class with many subclasses, you must list all of them in the permits clause and mark each as final, sealed, or non-sealed. This can be a large change, but it forces you to document the intended hierarchy explicitly. For subclasses that you do not control, you can use non-sealed to keep them extensible, but that weakens the guarantee.
Another production consideration is serialization. Sealed classes and records are subject to the same serialization rules as other classes. If you serialize instances of a sealed hierarchy, you must ensure that all permitted subtypes are serializable and that the class definitions remain compatible across versions. Adding a new subtype later will break deserialization of old data unless you handle it explicitly.
Performance is rarely a concern with sealed classes. The compiler generates the same bytecode as for ordinary classes; the sealed modifier is a compile-time concept and does not add runtime overhead. The main cost is the added design discipline, which usually pays off in fewer bugs and clearer code.