Skip to main content

Sealed Classes (Java 17+)

A sealed class explicitly lists every class that is permitted to extend it — giving you a closed type hierarchy that the compiler can enforce, and enabling switch expressions to be exhaustive without a default branch.

Note: Clarifications — sealed classes were finalized in JDK 17 (see JEP 409). The permits rules are module/package-sensitive; consult the JEP for exact visibility and JVM classfile attributes. Treat exhaustiveness guarantees as compile-time checks — runtime behavior depends on the compiled code and permitted subclasses.

What Problem Does It Solve?​

In Java, any class can be subclassed by default. This is great for open extension points, but for domain types with a fixed set of variants, open extension is a liability.

Consider a payment result: it's either a Success, a Failure, or a Pending — and nothing else ever. If PaymentResult is an open class:

  • Someone outside your package can subclass it with an UnknownResult
  • Your switch can't guarantee it handles all cases — so the compiler forces a default branch
  • A reviewer can't tell by reading the API that the variants are exactly three

Sealed classes fix this: you declare exactly who the permitted subclasses are, the compiler enforces it, and switch expressions can match all variants exhaustively without a default fallback.

What Is It?​

A sealed class (or interface) uses the permits clause to list the only classes allowed to extend/implement it:

public sealed class PaymentResult
permits SuccessResult, FailureResult, PendingResult {
}

Each permitted subclass must be one of:

  • final — no further extension
  • sealed — extends the hierarchy with its own restrictions
  • non-sealed — re-opens the class for arbitrary extension

How It Works​

Basic Syntax​

// The sealed parent
public sealed class Shape
permits Circle, Rectangle, Triangle {}

// Permitted subclasses — must be in same package (or module)
public final class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
public double radius() { return radius; }
}

public final class Rectangle extends Shape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
public double width() { return width; }
public double height() { return height; }
}

public final class Triangle extends Shape {
private final double base;
private final double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
public double base() { return base; }
public double height() { return height; }
}

Exhaustive Pattern Matching​

Once the hierarchy is sealed, the compiler knows all subtypes. A switch expression can cover them all without a default:

double area = 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' needed — every Shape has been handled
};

If you later add a new shape (e.g., Hexagon) to the permits list but forget to update the switch, the compiler flags it as a compilation error — not a runtime crash. This is a huge safety improvement.

The Three Subclass Modifiers​

final closes a branch; sealed creates a sub-hierarchy with its own permits; non-sealed re-opens a branch to the world (use sparingly).

Sealed Interfaces​

Sealed works on interfaces too — and this is the most idiomatic modern pattern:

public sealed interface PaymentResult
permits PaymentResult.Success, PaymentResult.Failure, PaymentResult.Pending {

// Nested records work perfectly — concise and cohesive
record Success(String transactionId, double amount) implements PaymentResult {}
record Failure(String errorCode, String message) implements PaymentResult {}
record Pending(String pendingId, String eta) implements PaymentResult {}
}

Usage:

PaymentResult result = processPayment(order);

String message = switch (result) {
case PaymentResult.Success s -> "Paid " + s.amount() + " (tx: " + s.transactionId() + ")";
case PaymentResult.Failure f -> "Failed: " + f.message() + " [" + f.errorCode() + "]";
case PaymentResult.Pending p -> "Pending — ETA: " + p.eta();
};

This is the sealed interface + nested records pattern — clean, type-safe, and compiler-verified. It's the Java equivalent of algebraic data types (ADTs) in functional languages like Haskell or Scala.

Location Rules​

Permitted subclasses must be in the same compilation unit or the same package as the sealed class:

com.example.payment
├── PaymentResult.java ← sealed interface
├── SuccessResult.java ← permitted — same package ✅
├── FailureResult.java ← permitted — same package ✅
└── PendingResult.java ← permitted — same package ✅

com.example.other
└── CustomResult.java ← NOT permitted — different package ❌

If using modules (Java 9+), permitted subclasses can be in different packages within the same module.

Code Examples​

Practical Demo

See the Sealed Classes Demo for step-by-step runnable examples.

Result Type — Railway-Oriented Design​

public sealed interface Result<T>
permits Result.Ok, Result.Err {

record Ok<T>(T value) implements Result<T> {}
record Err<T>(String message) implements Result<T> {}

// Utility: map over the success case
default <R> Result<R> map(java.util.function.Function<T, R> mapper) {
return switch (this) {
case Ok<T> ok -> new Ok<>(mapper.apply(ok.value()));
case Err<T> err -> new Err<>(err.message());
};
}
}

// Usage
Result<Integer> result = divide(10, 2);

String output = switch (result) {
case Result.Ok<Integer> ok -> "Result: " + ok.value();
case Result.Err<Integer> err -> "Error: " + err.message();
};

Result<Integer> divide(int a, int b) {
if (b == 0) return new Result.Err<>("Division by zero");
return new Result.Ok<>(a / b);
}

AST Node Modeling (Compiler / Expression Evaluator)​

public sealed interface Expr
permits Expr.Num, Expr.Add, Expr.Mul {

record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
}

double eval(Expr expr) {
return switch (expr) {
case Expr.Num n -> n.value();
case Expr.Add a -> eval(a.left()) + eval(a.right());
case Expr.Mul m -> eval(m.left()) * eval(m.right());
};
}

// Evaluates: (3 + 4) * 2 = 14.0
double result = eval(new Expr.Mul(
new Expr.Add(new Expr.Num(3), new Expr.Num(4)),
new Expr.Num(2)
));

This is a classic use case: sealed classes model a closed set of expression types, and recursive switch evaluates them without any instanceof casting.

Trade-offs & When To Use / Avoid​

ProsCons
UseExhaustive switch without default — compiler-checkedOnly works for truly closed hierarchies
UseDocuments intent: "these are the only variants"Permitted classes must be in same package/module
UseReplaces brittle instanceof chains elegantlyNot a fit for open extension points (use interfaces)
UseWorks beautifully with records as implementationsPre-Java 17 codebases can't use it
AvoidOpen APIs designed for third-party extension
AvoidHierarchies where new variants are expected from outside the module

Common Pitfalls​

Using non-sealed loosely — defeating the purpose:

// If ALL permitted subclasses are non-sealed, you've lost exhaustiveness
sealed class Foo permits Bar {}
non-sealed class Bar extends Foo {}
// Now anyone can extend Bar, and switch on Foo can't be exhaustive

Forgetting to update switch when adding a new variant:

// This is actually a FEATURE, not a pitfall — the compiler catches it:
// error: the switch expression does not cover all possible input values
// Add the new case and the code will compile again.

Confusing sealed with final:

  • final means "this specific class can't be extended at all".
  • sealed means "only the listed classes can extend this class". The listed classes themselves can be non-final.

Permitted classes not being adjacent:

// Classes in different packages fail unless you're using modules
// com.a.Shape (sealed, permits com.b.Circle) — compile error unless modular

Interview Questions​

Beginner​

Q: What is a sealed class in Java?
A: A sealed class (Java 17+) uses the sealed keyword with a permits clause to list the only classes that may extend it. This creates a closed type hierarchy. Each permitted subclass must be final, sealed, or non-sealed. Sealed classes let the compiler provide exhaustive switch expressions without a default branch.

Q: What modifiers are permitted subclasses required to use?
A: One of exactly three: final (no further extension), sealed (extends the hierarchy with its own permits), or non-sealed (re-opens extension to any class). Not using one of these is a compile error.

Intermediate​

Q: Why would you prefer a sealed interface with record implementations over a traditional class hierarchy?
A: It's more concise, immutable by default (records), and requires zero boilerplate (equals, hashCode, toString come free). The sealed interface declares the variants; each record provides the data structure for that variant. The combination models algebraic data types (ADTs) natively in Java, enabling safe, exhaustive pattern matching.

Q: How does a sealed class enable exhaustive switch expressions?
A: When the compiler knows all permitted subtypes (because the hierarchy is sealed), it can verify that every case in a switch is covered. If you add a new permitted subtype and don't add a corresponding case, the compiler reports an error. With an unsealed hierarchy, the compiler can't guarantee completeness and requires a default clause.

Advanced​

Q: How do sealed classes relate to algebraic data types (ADTs)?
A: ADTs from functional programming (Haskell's data, Rust's enum, Scala's sealed trait) represent a type that is exactly one of a fixed set of variants, each with its own data. Java's sealed interfaces + record implementations are the direct equivalent: the sealed interface is the sum type, and the records are the product types (variants). Pattern matching (switch) on them is the case expression of functional languages.

Q: What happens if you add a new variant to a sealed hierarchy that is consumed by a library?
A: Downstream code that uses the sealed type in a switch expression will fail to compile (not at runtime) — the switch is no longer exhaustive. This is intentional: the compiler forces callers to handle the new case. This is a source-incompatible but type-safe change. Contrast with inheritance-based designs, where a new subclass causes runtime ClassCastExceptions in instanceof chains — which are much harder to track down.

Further Reading​

  • Records (Java 16+) — records are the idiomatic implementation type for sealed hierarchy variants.
  • Polymorphism — sealed classes improve polymorphism safety by making switch exhaustive and eliminating unchecked instanceof chains.
  • Abstraction — sealed interfaces are a form of restricted abstraction: they define a contract that only trusted types implement.