Sealed Classes in Java 17: Controlled Inheritance and Exhaustive Pattern Matching
Java 17 sealed classes let you lock down inheritance hierarchies and get compile‑time exhaustive pattern matching. This post shows how to declare a sealed interface, use it in a switch expression, and what to watch out for with proxies and modules.
04 Oct 2026, 00:23 UTC

The problem: runaway subclassing
When a library exposes a base type, any downstream code can extend it. That freedom is useful for plug‑ins, but it also makes exhaustive reasoning impossible — a switch on the base type can never be proven complete, and maintainers cannot guarantee that new subclasses won’t break invariants.
What sealed classes give you
Java 17 introduced sealed classes (JEP 409). A sealed class or interface declares a fixed set of permitted subclasses. The compiler enforces two things:
- Only the listed types may extend the sealed type.
- When you pattern‑match on the sealed type, the compiler can verify that every permitted subclass is handled.
Think of it as a compile‑time contract: “these are the only implementations that will ever exist.”
Declaring a sealed hierarchy
You can list permitted subclasses in a permits clause or nest them directly. The following example uses an interface because it works well with pattern matching for switch (Java 21+).
// Shape.java
public sealed interface Shape permits Circle, Rectangle {}
// Circle.java
public final class Circle implements Shape {
public final double radius;
public Circle(double radius) { this.radius = radius; }
}
// Rectangle.java
public final class Rectangle implements Shape {
public final double width, height;
public Rectangle(double width, double height) {
this.width = width; this.height = height;
}
}
All three files live in the same package (or module). The final modifier on the implementations prevents further subclassing, but you could also use non‑sealed if you want a branch to stay open.
Exhaustive pattern matching with switch
Java 21’s pattern matching for switch (JEP 441) lets the compiler check exhaustiveness automatically.
// AreaCalculator.java
public class AreaCalculator {
public static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius * c.radius;
case Rectangle r -> r.width * r.height;
// no default needed — compiler knows the list is complete
};
}
}
If you later add a new permitted subclass (say Triangle) but forget to update the switch, the compiler emits:
error: the switch expression does not cover all permitted subclasses of Shape
case Triangle t -> ...
This turns a potential runtime MatchException into a compile‑time error.
Worked example: verifying the compiler checks
- Save the four files above in a directory
src/. - Open a terminal in that directory (no special permissions required).
- Compile:
javac -d out src/*.java— succeeds. - Create a third implementation
Triangle.javathat implementsShapebut is not listed inpermits. - Re‑compile. The compiler reports:
error: class Triangle is not allowed to extend sealed interface Shape - Now add
Triangleto thepermitsclause, recompile, and remove thecase Triangleline fromAreaCalculator. The compiler now flags the non‑exhaustiveswitch.
These steps demonstrate both enforcement directions without any runtime execution.
Trade‑offs and limitations
- Dynamic proxies / byte‑code manipulation: Frameworks such as Spring, Hibernate, or Mockito create subclasses at runtime. A sealed type cannot be proxied because the generated proxy would be an unpermitted subclass. Work‑around: keep the sealed type out of the proxied API or use
non‑sealedon a wrapper interface. - Module boundaries: If the sealed type and its permitted subclasses live in different modules, the module declaration must export the sealed type and include a
permitsdirective. Otherwise the compiler rejects cross‑module subclassing. - Refactoring cost: Adding or removing a permitted subclass requires editing the sealed declaration (and possibly the module descriptor). This is intentional — it forces a deliberate decision — but it can be noisy in large codebases.
Actionable next step
Identify a domain model in your codebase that currently uses an open class hierarchy (e.g., payment methods, command types, AST nodes). Replace the root with a sealed interface and list the existing concrete classes in permits. Compile the module; any stray subclass will surface immediately. Then rewrite the corresponding switch expressions — if the compiler stays silent, you have gained compile‑time exhaustiveness for free.
Run the verification steps above on a small test project first. Once comfortable, roll the pattern out to the production codebase incrementally.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.