Sealed Classes
Sealed classes (Java 17+) let you explicitly enumerate every class allowed to extend or implement a type, giving the compiler enough information to check that your logic handles every case.
A sealed class or interface restricts which other classes or interfaces may extend or implement it. Instead of an open hierarchy anyone can extend, the author lists a fixed, closed set of permitted direct subtypes with the permits clause. This closed-world knowledge is what lets switch pattern matching over a sealed hierarchy be checked for exhaustiveness by the compiler.
What Is a Sealed Class
Declaring a class or interface sealed means only the classes/interfaces named in its permits clause may directly extend or implement it. This is the opposite of Java's traditional open extension model, where any class can extend any non-final public class.
Shape can only ever be Circle, Square, or Triangle - no other class anywhere can implement it.
public sealed interface Shape permits Circle, Square, Triangle { }
public final class Circle implements Shape {
public final double radius;
public Circle(double radius) { this.radius = radius; }
}
public final class Square implements Shape {
public final double side;
public Square(double side) { this.side = side; }
}
public final class Triangle implements Shape {
public final double base, height;
public Triangle(double base, double height) { this.base = base; this.height = height; }
}The permits Clause
permits names the direct permitted subtypes explicitly. If all permitted subtypes are declared in the same source file as the sealed type, the permits clause can be omitted — the compiler infers it from every subtype declared in that file. Across separate files, permits is required.
Every permitted subtype must be in the same module (or, for an unnamed module, the same package) as the sealed type - this is a JLS requirement, not just convention, so the compiler can actually see and verify every permitted subtype.
// Same file - permits clause can be omitted
sealed interface Vehicle { }
final class Car implements Vehicle { }
final class Truck implements Vehicle { }
// Compiler infers: permits Car, TruckEvery Direct Subtype Must Declare Its Own Modifier
Each class named in permits must itself declare exactly one of final, sealed, or non-sealed — there is no implicit default. This forces every level of the hierarchy to explicitly state whether extension stops here, continues under further restriction, or reopens.
sealed interface Shape permits Circle, Polygon { }
final class Circle implements Shape { } // closes extension entirely
sealed interface Polygon extends Shape
permits Square, Triangle { } // still restricted further down
final class Square implements Polygon { }
final class Triangle implements Polygon { }final Subclasses
A permitted subtype marked final ends the hierarchy at that branch — nothing may extend it further. This is the most common choice for leaf types in a sealed hierarchy, especially records, which are implicitly final.
sealed interface Result<T> permits Success, Failure { }
final class Success<T> implements Result<T> {
final T value;
Success(T value) { this.value = value; }
}
final class Failure<T> implements Result<T> {
final String error;
Failure(String error) { this.error = error; }
}non-sealed Subclasses
A permitted subtype marked non-sealed reopens that branch of the hierarchy to unrestricted extension — any class can extend it going forward. This is useful when one branch genuinely needs to stay open (e.g. a plugin type) while the rest of the hierarchy stays closed.
non-sealed is an explicit, visible opt-out - it's obvious in the source that this branch deliberately breaks the closed-world guarantee.
Because non-sealed reopens the hierarchy, exhaustiveness checking in a switch over the sealed type can no longer assume CustomShape has a known, finite set of subtypes - the compiler still requires a default or a case for CustomShape itself.
sealed interface Shape permits Circle, CustomShape { }
final class Circle implements Shape { }
non-sealed interface CustomShape extends Shape { }
// Anyone, anywhere, can now implement CustomShape:
class UserDefinedShape implements CustomShape { }Sealed Classes vs Sealed Interfaces
Both classes and interfaces can be sealed, with the same rules: direct subtypes/implementers must be named in permits (or inferred from same-file declarations) and must be final, sealed, or non-sealed. A sealed abstract class can also declare shared state and behavior that its permitted subclasses inherit — something a sealed interface cannot do beyond default methods.
public abstract sealed class Employee permits Manager, Engineer {
protected final String name;
protected Employee(String name) { this.name = name; }
public abstract double salary();
}
public final class Manager extends Employee {
public Manager(String name) { super(name); }
public double salary() { return 120_000; }
}
public final class Engineer extends Employee {
public Engineer(String name) { super(name); }
public double salary() { return 100_000; }
}Controlled Hierarchies: Why Seal Anything
Sealing trades openness for guarantees. In an open hierarchy, any code anywhere can add a new subtype at any time, so consumers of the type (switch statements, visitor implementations, exhaustive if/instanceof chains) can never be sure they've handled every case. Sealing pins the set of subtypes at compile time within the same module/package, so the compiler CAN verify exhaustiveness — and any addition of a new case shows up as a compile error everywhere that needs updating, not a silent runtime gap.
Relationship to Pattern Matching and switch Exhaustiveness
The primary payoff of sealed types is exhaustive pattern matching in a switch expression: because the compiler knows the complete, closed set of subtypes, a switch over a sealed type that covers every permitted subtype does not need a default branch — the compiler proves it's exhaustive on its own. Add a new permitted subtype later, and every such switch that doesn't handle it becomes a compile error, not a silently-wrong runtime default.
If a fourth record implementing Shape were added, this switch would fail to compile until a case for it was added.
sealed interface Shape permits Circle, Square, Triangle { }
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
record Triangle(double base, double height) implements Shape { }
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> 0.5 * t.base() * t.height();
// no default needed - compiler proves these three cover every case
};
}Sealed Classes with Generics
Sealed types can be generic; the permits clause lists raw type names (subclasses can be parameterized independently, but each named subtype must itself implement/extend the sealed supertype).
sealed interface Either<L, R> permits Left, Right { }
record Left<L, R>(L value) implements Either<L, R> { }
record Right<L, R>(R value) implements Either<L, R> { }Practical Usage
Sealed hierarchies fit naturally wherever a domain has a genuinely fixed, small set of variants: an AST node type, a result/error type, a state machine's states, a payment method type, or an HTTP response outcome. Combined with records for the leaf types and pattern matching in switch, this gives you algebraic-data-type-style modeling that reads and checks similarly to sum types in functional languages.
Edge Cases
A sealed class cannot be extended by an anonymous class, a local class, or a lambda-backed functional interface implementation from outside its permitted set — those are all just ordinary attempts to add an unlisted subtype and are rejected the same way. Reflection (Class.isSealed(), Class.getPermittedSubclasses()) can inspect a sealed type's permitted subtypes at runtime, but reflection cannot bypass the compile-time restriction to create a genuinely new subtype outside the permitted set through normal class loading in the same module.
sealed interface Shape permits Circle { }
final class Circle implements Shape { }
// class Hexagon implements Shape { } // COMPILE ERROR - Hexagon not in permits
System.out.println(Shape.class.isSealed()); // true
for (var c : Shape.class.getPermittedSubclasses()) {
System.out.println(c); // interface Circle... (via reflection)
}Mental Model
Think of a sealed type as an enum of shapes rather than an enum of values — instead of a fixed set of named constants, you get a fixed set of named subtypes, each of which can carry its own distinct data and behavior. The compiler treats that closed set the same way it treats a fixed enum's set of values when checking switch exhaustiveness.
Quick Reference
sealed interface/class T permits A, B, C { }
// permits omittable if A, B, C are all in the same source file
A must be: final | sealed | non-sealed (one is required)
final -> branch closed, no further extension
sealed -> branch restricted further, needs its own permits
non-sealed -> branch reopened to unrestricted extension
switch over sealed type covering all permitted subtypes -> no default neededKey Takeaways
sealed restricts which classes/interfaces may directly extend or implement a type to those named in permits (or inferred from same-file declarations).
Every permitted direct subtype must declare final, sealed, or non-sealed - there is no implicit default.
non-sealed deliberately reopens a branch of an otherwise closed hierarchy to unrestricted extension.
Permitted subtypes must live in the same module (or package, for an unnamed module) as the sealed type.
The main payoff is compiler-checked exhaustiveness in switch pattern matching over the sealed type, without needing a default branch.
sealed interface Shape permits Circle, Square { } class Circle implements Shape { } // COMPILE ERROR - missing modifier sealed interface Shape permits Circle, Square { } final class Circle implements Shape { } final class Square implements Shape { }
Every direct permitted subtype must explicitly declare final, sealed, or non-sealed - none is implied by default.
// Circle.java in package a; Square.java in package b, different module sealed interface Shape permits a.Circle, b.Square { } // COMPILE ERROR
// All types in the same module/package sealed interface Shape permits Circle, Square { }
Permitted subtypes must reside in the same module (or same package, for an unnamed module) as the sealed type - this is enforced by the compiler, not just convention.
static double area(Shape s) { return switch (s) { case Circle c ->...; default -> 0; // masks a missing case instead of a compile error }; } static double area(Shape s) { return switch (s) { case Circle c ->...; case Square sq ->...; case Triangle t ->...; // no default - compiler verifies exhaustiveness }; }
Adding a catch-all default over a sealed type throws away the main benefit: the compiler can no longer flag a genuinely missing case when a new subtype is added later.
What is the difference between not sealing a class at all and sealing it with an empty-feeling permits of just one subtype?
An unsealed class can be extended by anything, anywhere, at any time. A sealed class with permits X restricts extension to exactly X (and only X can extend it, subject to X's own modifier) - the set is closed and compiler-verified even if it has just one entry.
Why must every permitted subtype declare final, sealed, or non-sealed?
So the extension policy is explicit at every level of the hierarchy rather than defaulting silently. It forces the author of each subtype to consciously decide whether the hierarchy stays closed, narrows further, or deliberately reopens.
What guarantee does sealing give the compiler that a normal open hierarchy cannot?
That the complete set of direct subtypes is known and fixed at compile time (within the same module/package), which lets the compiler verify that a switch expression over the sealed type covers every case, without needing a default.
Can a sealed interface's permitted subtype be a record?
Yes, and this is one of the most common patterns - records are implicitly final, so a record implementing a sealed interface automatically satisfies the 'must declare final/sealed/non-sealed' requirement.
What happens to exhaustiveness checking on a switch over a sealed type after one branch is marked non-sealed?
The compiler can no longer assume that branch has a fixed, known set of further subtypes, so a switch must still include a case for that non-sealed type itself (or a default) to remain exhaustive.
Where must a permitted subtype of a sealed type be located?
In the same module as the sealed type, or in the same package if the sealed type is in an unnamed module - the compiler enforces this so it can actually verify the complete subtype set.
Why might a team choose sealed classes over a plain enum for modeling a fixed set of variants?
Sealed types let each variant carry its own distinct fields and behavior (unlike enum constants, which all share one type's shape), which is essential for modeling something like an AST node or a Result type where each case has genuinely different data.
Write a sealed interface TrafficLight with three permitted record implementations: Red, Yellow, Green (no fields needed), and a switch expression returning a String description for each.
sealed interface TrafficLight permits Red, Yellow, Green { } record Red() implements TrafficLight { } record Yellow() implements TrafficLight { } record Green() implements TrafficLight { } static String describe(TrafficLight light) { return switch (light) { case Red r -> "Stop"; case Yellow y -> "Caution"; case Green g -> "Go"; }; }
Because TrafficLight is sealed with exactly these three permitted types (all records, hence implicitly final), the switch is exhaustive without a default clause.
Does the Kitten class compile? Why or why not?
Yes, it compiles. Cat is declared non-sealed, which reopens that branch of the hierarchy to unrestricted extension - any class, including Kitten, may extend Cat freely.
Write a sealed abstract class Expr representing arithmetic expressions with permitted subtypes Num (holds an int) and Add (holds two Expr operands), plus a method to evaluate an Expr.
sealed abstract class Expr permits Num, Add { abstract int eval(); } final class Num extends Expr { final int value; Num(int value) { this.value = value; } int eval() { return value; } } final class Add extends Expr { final Expr left, right; Add(Expr left, Expr right) { this.left = left; this.right = right; } int eval() { return left.eval() + right.eval(); } }
Add(new Num(2), new Num(3)).eval() returns 5. Because Expr is sealed to exactly Num and Add, any future switch-based evaluator over Expr can be checked for exhaustiveness by the compiler.
Does adding Triangle to the same file, without updating the permits clause, compile?
No - Shape's permits clause explicitly lists only Circle and Square. An explicit permits clause is not automatically extended by adding more implementers in the same file; you would need to add Triangle to the permits list (or omit permits entirely and let the compiler infer it from all same-file implementers).
sealed interface Animal permits Dog, Cat { }
final class Dog implements Animal { }
non-sealed class Cat implements Animal { }
class Kitten extends Cat { } // is this legal?sealed interface Shape permits Circle, Square { }
final class Circle implements Shape { }
final class Square implements Shape { }
// Add later, in the same file:
final class Triangle implements Shape { }