Restrict class hierarchies with sealed/permits — the foundation of pattern matching.
Published September 21, 2026
Normally anyone can extend a non-final class or implement a public interface. That's flexible, but it means the compiler can never know all the subtypes of a type. So code that handles "every kind of Shape" needs a default branch "just in case", and adding a new subtype silently falls into that branch instead of being handled.
A sealed class or interface (final in Java 17) lists exactly which types may extend it. The hierarchy is closed, and that lets the compiler check that you've handled every case. It's Java's version of what functional languages call a sum type or algebraic data type: "a Shape is either a Circle or a Rectangle or a Triangle, and nothing else".
public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
Records are the natural partner for sealed types: each variant carries its own data, is immutable, and is implicitly final. If the subtypes are declared in the same file as the sealed type, the permits clause can be omitted and the compiler infers it.
final: no further subclasses (records and enums are implicitly final);sealed: its own closed list of subtypes;non-sealed: opens that branch back up so anyone can extend it (a deliberate escape hatch).public sealed abstract class Vehicle permits Car, Truck, Bike {}
public final class Car extends Vehicle {}
public sealed class Truck extends Vehicle permits PickupTruck, SemiTruck {} // further closed
public non-sealed class Bike extends Vehicle {} // anyone may extend Bike
With a sealed type, a switch that covers every permitted subtype needs no default, and the compiler verifies it's complete:
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();
}; // no default: all cases covered
}
Now add record Hexagon(double side) implements Shape {} to the permits list. Every switch over Shape that lacks a Hexagon case stops compiling. That's the key benefit: the compiler finds every place that must handle the new variant, instead of a default branch silently swallowing it at runtime.
Combined with record patterns (see Pattern Matching), you can take the data apart right in the case:
case Rectangle(double w, double h) when w == h -> "square of side " + w;
case Rectangle(double w, double h) -> "rectangle " + w + "×" + h;
A classic use is a result type where each outcome carries different data:
public sealed interface PaymentResult {
record Approved(String transactionId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record RequiresAction(URI challengeUrl) implements PaymentResult {} // e.g. 3-D Secure
}
String message(PaymentResult r) {
return switch (r) {
case PaymentResult.Approved a -> "Paid, ref " + a.transactionId();
case PaymentResult.Declined d -> "Declined: " + d.reason();
case PaymentResult.RequiresAction x -> "Please verify at " + x.challengeUrl();
};
}
Compared with throwing exceptions for expected outcomes, or returning a class with nullable fields for every case, this makes each outcome explicit, type-safe and impossible to forget.
Other good fits: commands and events in an event-driven system, states in a state machine, AST nodes in a parser, API responses with a fixed set of shapes.
| Enum | Sealed hierarchy | Open inheritance | |
|---|---|---|---|
| Fixed set of variants | ✅ | ✅ | ❌ |
| Each variant carries different data | ❌ (same fields for all) | ✅ | ✅ |
| Multiple instances per variant | ❌ (one instance each) | ✅ | ✅ |
| Exhaustive switch checking | ✅ | ✅ | ❌ |
| Third parties can add variants | ❌ | ❌ (unless non-sealed) | ✅ |
Use an enum for a fixed set of constants (OrderStatus), a sealed hierarchy when the variants hold different data, and open inheritance when extension by others is the point (a plugin API).
default defeats the purpose. Adding a default to a switch over a sealed type compiles, but turns off the "you forgot a case" check for future variants.@JsonTypeInfo/@JsonSubTypes to record which variant each JSON object is.Q: What problem do sealed classes solve that final doesn't?
A: final allows zero subclasses, and an open class allows any. Sealed sits in between: an exact, known list. That knowledge lets the compiler check switches exhaustively, and it documents the design intent that "these are the only kinds".
Q: Why must each permitted subclass be final, sealed or non-sealed?
A: So the author explicitly decides how the closed hierarchy continues at every level. Without the rule, a permitted subclass could quietly be extended by anyone, and the "closed set" guarantee would be meaningless.
Q: Do I need permits?
A: Not if all the subtypes are declared in the same source file as the sealed type (for example, nested records inside a sealed interface). The compiler infers the list. Otherwise it's required.
Q: How is a sealed interface of records different from an enum?
A: Enum constants are single instances with the same fields. Sealed records can each have different fields and many instances (new Declined("insufficient funds"), new Declined("card expired")). Both give exhaustive switches.
Q: What happens at runtime if a new subtype sneaks in after compilation, e.g. through a changed library?
A: The compiler inserts a hidden default into exhaustive switches that throws a MatchException (an IncompatibleClassChangeError for enum switches in older versions), so you fail loudly rather than silently doing the wrong thing.