Skip to content

Java · Modern Java

Java Sealed Classes — Closed Hierarchies, Exhaustive Switch, and ADTs

7 min read Updated 2026-06-20 Share:

Practice Sealed Classes interview questions

The problem with open inheritance

Before sealed classes, a public abstract class Shape or public interface Shape could be extended by anyone, anywhere. This is fine for true extension points but problematic when you own the complete set of subtypes — you can never safely write an exhaustive if/else instanceof chain because the compiler can't verify you've handled every case.

Sealed classes (finalized in Java 17, JEP 409) solve this by letting you declare exactly which types may directly extend or implement a class or interface.

Basic syntax

public sealed interface Shape permits Circle, Rectangle, Triangle {}

public record Circle(double radius)       implements Shape {}
public record Rectangle(double w, double h) implements Shape {}
public record Triangle(double base, double h) implements Shape {}

Shape can now only be implemented by Circle, Rectangle, and Triangle. Any other class attempting implements Shape gets a compile error. The three permitted types are record (implicitly final), which naturally closes the hierarchy.

The permits clause — explicit and implicit

When all permitted subtypes are in the same source file as the sealed type, permits can be omitted and the compiler infers it:

// Single file — permits inferred:
sealed class Result<T> {
    record Ok<T>(T value) extends Result<T> {}
    record Err<T>(String msg) extends Result<T> {}
}

When subtypes are in separate files, the permits clause is required, and the subtypes must reside in the same package (non-modular) or same module.

Permitted subtype modifiers: final, sealed, non-sealed

Every permitted subtype must declare one of three modifiers:

final — closes the branch. No further subclasses allowed.

sealed — extends the hierarchy but restricts it further with its own permits.

non-sealed — deliberately re-opens the hierarchy. Anyone can extend this subtype.

sealed interface Notification permits Email, Push, Sms {}

final class Email implements Notification {}       // leaf — closed

sealed class Push implements Notification
    permits AndroidPush, IosPush {}                // further sealed
final class AndroidPush extends Push {}
final class IosPush     extends Push {}

non-sealed class Sms implements Notification {}    // re-opened
class SmsPremium extends Sms {}                    // anyone can extend Sms

non-sealed is the escape hatch for intentional extension points inside an otherwise closed hierarchy — use it sparingly and document it as a public API contract.

Sealed classes vs abstract classes vs enums

abstract classsealed classenum
Prevents direct instantiationYesOnly if also abstractYes (only constants)
Restricts subclassingNoYes — permits listN/A
Per-variant dataSame fieldsDifferent per subtypeSame fields for all constants
Exhaustive switch (Java 21)NoYesYes

Use enum when all variants are structurally identical singletons. Use sealed class when variants carry different data or need their own logic.

// Enum: all constants have the same shape
enum Status { PENDING, ACTIVE, CLOSED }

// Sealed: each variant has distinct structure
sealed interface Event permits OrderPlaced, OrderShipped, OrderCancelled {}
record OrderPlaced(String id, Instant at)       implements Event {}
record OrderShipped(String id, String carrier)  implements Event {}
record OrderCancelled(String id, String reason) implements Event {}

Exhaustive switch expressions

The killer feature of sealed types: because the compiler knows the complete set of permitted subtypes, a switch expression over a sealed type can be exhaustively verified at compile time. No default branch needed:

double area(Shape s) {
    return switch (s) {
        case Circle c        -> Math.PI * c.radius() * c.radius();
        case Rectangle r     -> r.w() * r.h();
        case Triangle t      -> 0.5 * t.base() * t.h();
    };
}

If you later add record Pentagon(double side) implements Shape {} without updating area(), the compiler immediately reports an error at the switch. The exhaustive check guides every update — no runtime IllegalStateException from a forgotten case.

Combining sealed classes with record deconstruction

Java 21 allows record patterns inside switch, destructuring component values inline:

sealed interface Expr permits Num, Add, Mul {}
record Num(int v)           implements Expr {}
record Add(Expr l, Expr r)  implements Expr {}
record Mul(Expr l, Expr r)  implements Expr {}

int eval(Expr e) {
    return switch (e) {
        case Num(int v)          -> v;
        case Add(Expr l, Expr r) -> eval(l) + eval(r);
        case Mul(Expr l, Expr r) -> eval(l) * eval(r);
    };
}

System.out.println(eval(new Add(new Num(3), new Mul(new Num(4), new Num(5))))); // 23

No visitor interface, no accept() methods, no double dispatch — the compiler handles exhaustiveness and the pattern matching handles dispatch. This models recursive ASTs, JSON documents, domain events, or any sum type cleanly.

Modelling algebraic data types (ADTs)

Sealed interfaces are Java's answer to sum types (also called tagged unions or variant types) in languages like Rust, Haskell, or Kotlin. A sealed interface + record variants describes a type that is exactly one of a fixed set of shapes:

// Option type:
sealed interface Option<T> permits Option.Some, Option.None {
    record Some<T>(T value) implements Option<T> {}
    record None<T>()        implements Option<T> {}
}

// Result type:
sealed interface Result<T> permits Result.Ok, Result.Err {
    record Ok<T>(T value)    implements Result<T> {}
    record Err<T>(String msg) implements Result<T> {}
}

Result<Integer> parse(String s) {
    try { return new Result.Ok<>(Integer.parseInt(s)); }
    catch (NumberFormatException e) { return new Result.Err<>(e.getMessage()); }
}

// Caller must handle both branches:
switch (parse("abc")) {
    case Result.Ok<Integer>(int v)   -> System.out.println("Got: " + v);
    case Result.Err<Integer>(String m) -> System.err.println("Error: " + m);
}

Compare this to returning null or throwing an exception — sealed Result makes the error path explicit in the type system. Callers can't ignore it.

Sealed interfaces in the JDK

The JDK itself uses sealed types. java.lang.constant.ConstantDesc is a sealed interface with permitted types ClassDesc, MethodTypeDesc, MethodHandleDesc, and DynamicConstantDesc. The constant pool representation of class files is modelled as a closed hierarchy — a natural fit.

Runtime inspection: getPermittedSubclasses()

You can query the permitted subtypes at runtime, useful for frameworks that need to enumerate all variants for serialization or schema generation:

Class<?>[] subs = Shape.class.getPermittedSubclasses();
// [class Circle, class Rectangle, class Triangle]

This removes the need for classpath scanning or maintaining a hand-rolled registry of subtypes.

Sealed classes replace the Visitor pattern

The Visitor pattern existed to add new operations over a closed type hierarchy without modifying each class. Sealed types + switch pattern matching achieve the same goal with less ceremony:

// Visitor pattern — 30+ lines of boilerplate:
interface ShapeVisitor<R> {
    R visitCircle(Circle c);
    R visitRectangle(Rectangle r);
}
// Each Shape must implement accept(ShapeVisitor<R>)...

// Sealed + switch — same result, no boilerplate:
double area(Shape s) {
    return switch (s) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.w() * r.h();
    };
}

Adding a new operation means writing a new switch; adding a new variant breaks every existing switch at compile time — the compiler tells you exactly what to update.

Recap

Sealed classes (Java 17) close an inheritance hierarchy to a named permits list. Each permitted subtype must be final (leaf), sealed (further restricted), or non-sealed (deliberately re-opened). Sealed interfaces combine with records to model algebraic sum types — each variant carries different data. The payoff is exhaustive switch expressions (Java 21) where the compiler verifies every case is handled, turning missed-variant runtime bugs into compile errors. The combination of sealed interfaces + records + switch pattern matching replaces the Visitor pattern with far less boilerplate, and getPermittedSubclasses() lets frameworks discover the closed type set at runtime.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel