The Problem Statement

You model a closed set of payment outcomes — success, decline, retry — then six months later someone adds a fourth subtype and half your instanceof chains never hear about it. The compiler stays quiet. Production does not.

Sealed types plus pattern switch are the language’s answer for that shape of domain: declare every allowed subtype up front, then handle them in one place with the compiler watching for gaps.

A sealed class or interface restricts which types may extend or implement it. Pair that closed hierarchy with pattern matching for switch and you get exhaustive, type-safe handling — no visitor boilerplate, no forgotten else branch. Reach for this when the set of variants is known and finite — not when third parties must plug in new subtypes freely.

This post builds on Java records: records carry the data; sealed types close the family; pattern switch does the dispatch.

Switch expressions (arrow cases, yield, no fall-through) are the prerequisite in Switch Expressions: Return a Value Without the Break Ritual; this post is the sealed + pattern-matching sequel.

The problem sealed types + pattern switch solve

Before sealed hierarchies, a “closed” domain was often an open interface plus tribal knowledge:

interface PaymentResult {}

class Success implements PaymentResult {
    final String transactionId;
    Success(String transactionId) { this.transactionId = transactionId; }
}

class Declined implements PaymentResult {
    final String reason;
    Declined(String reason) { this.reason = reason; }
}

class RetryLater implements PaymentResult {
    final Duration delay;
    RetryLater(Duration delay) { this.delay = delay; }
}

String describe(PaymentResult result) {
    if (result instanceof Success s) {
        return "ok:" + s.transactionId;
    } else if (result instanceof Declined d) {
        return "declined:" + d.reason;
    } else if (result instanceof RetryLater r) {
        return "retry:" + r.delay;
    }
    throw new IllegalStateException("unknown result: " + result.getClass());
}

That last throw is a runtime apology. Anyone can add Chargeback implements PaymentResult in another package; describe keeps compiling and keeps lying until a test or an incident notices.

With a sealed interface and an exhaustive switch, the same intent becomes a contract the compiler enforces:

sealed interface PaymentResult permits Success, Declined, RetryLater {}

record Success(String transactionId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record RetryLater(Duration delay) implements PaymentResult {}

static String describe(PaymentResult result) {
    return switch (result) {
        case Success(var id) -> "ok:" + id;
        case Declined(var reason) -> "declined:" + reason;
        case RetryLater(var delay) -> "retry:" + delay;
    };
}

Add Chargeback to permits without a matching case and the switch fails to compile. That is the whole point.

When it shipped

FeatureReleaseStatusSpec
Pattern matching for instanceofJava 16StandardJEP 394
Sealed classesJava 15–16PreviewJEP 360, JEP 397
Sealed classesJava 17 LTSStandardJEP 409
Pattern matching for switchJava 17–20PreviewJEP 406 and follow-ons
Record patternsJava 19–20PreviewJEP 405 and follow-ons
Pattern switch + record patternsJava 21 LTSStandardJEP 441, JEP 440

Seal the hierarchy on Java 17+. For exhaustive pattern switch with record deconstruction and no preview flags, use Java 21+. On 17–20 you can still seal types and use instanceof patterns; the full “one switch, no soup” experience lands with 21 LTS.

Mental model

Think in three layers:

PieceJob
Sealed typePublishes the full list of allowed subtypes via permits
Permitted subtypesMust be final, sealed, or non-sealed
Pattern switchMatches on type (and record components) with exhaustiveness checking

Rules that matter day to day:

  • A sealed class or interface and its permitted subtypes must live in the same module — or, if on the classpath without modules, the same package.
  • Every direct subtype must choose: stop the hierarchy (final or a record), continue sealing (sealed), or reopen it (non-sealed).
  • An exhaustive switch over a sealed type needs a case for every permitted subtype (or a covering default — but prefer listing cases so new permits break the build).
  • Records are implicitly final, so they are natural leaf types under a sealed interface.
sealed Shape
   ├── final / record Circle
   ├── final / record Rectangle
   └── sealed Polygon
          ├── record Triangle
          └── non-sealed FreeformPolygon  ← escape hatch for libraries

Basics in code

Declare a sealed hierarchy

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 a, double b, double c) implements Shape {}

You can seal a class the same way:

public sealed abstract class Expr permits Literal, Binary, Unary {
    // shared helpers if you need them
}

public record Literal(double value) extends Expr {}
public record Binary(Expr left, char op, Expr right) extends Expr {}
public record Unary(char op, Expr expr) extends Expr {}

Pattern matching for instanceof (Java 16+)

Even without switch, binding patterns remove the cast:

static double area(Shape shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.radius() * c.radius();
    }
    if (shape instanceof Rectangle r) {
        return r.width() * r.height();
    }
    if (shape instanceof Triangle t) {
        double s = (t.a() + t.b() + t.c()) / 2;
        return Math.sqrt(s * (s - t.a()) * (s - t.b()) * (s - t.c()));
    }
    throw new IllegalStateException("unreachable if Shape stays sealed");
}

Useful on Java 17 when you are not yet on 21 — but the throw is still a smell once pattern switch is available.

Exhaustive pattern switch (Java 21+)

static double area(Shape shape) {
    return switch (shape) {
        case Circle(var r) -> Math.PI * r * r;
        case Rectangle(var w, var h) -> w * h;
        case Triangle(var a, var b, var c) -> {
            double s = (a + b + c) / 2;
            yield Math.sqrt(s * (s - a) * (s - b) * (s - c));
        }
    };
}

Record patterns deconstruct components in the case label. Nested records nest cleanly:

record Point(int x, int y) {}
sealed interface Figure permits Line, Box {}
record Line(Point from, Point to) implements Figure {}
record Box(Point topLeft, Point bottomRight) implements Figure {}

static int width(Figure figure) {
    return switch (figure) {
        case Line(Point(var x1, var y1), Point(var x2, var y2)) -> Math.abs(x2 - x1);
        case Box(Point(var x1, var y1), Point(var x2, var y2)) -> Math.abs(x2 - x1);
    };
}

Bind only what you use when patterns get nested; unnamed patterns (_) arrive as a polish item on newer JDKs, but named var bindings are enough on Java 21.

Guarded cases refine a match:

static String sizeLabel(Shape shape) {
    return switch (shape) {
        case Circle(var r) when r < 1 -> "tiny circle";
        case Circle(var r) -> "circle r=" + r;
        case Rectangle(var w, var h) when w == h -> "square";
        case Rectangle(var w, var h) -> "rect " + w + "x" + h;
        case Triangle(var a, var b, var c) -> "triangle";
    };
}

Use cases

1. Domain variants that must stay closed

Payments, order states, AST nodes, and protocol messages are classic algebraic-data-type shapes. Seal the parent; make each variant a record; switch at the boundary:

sealed interface OrderStatus permits Placed, Paid, Shipped, Cancelled {}

record Placed(Instant at) implements OrderStatus {}
record Paid(Instant at, String paymentRef) implements OrderStatus {}
record Shipped(Instant at, String trackingId) implements OrderStatus {}
record Cancelled(Instant at, String reason) implements OrderStatus {}

static boolean isTerminal(OrderStatus status) {
    return switch (status) {
        case Shipped s -> true;
        case Cancelled c -> true;
        case Placed p -> false;
        case Paid p -> false;
    };
}

Every permitted type appears. Add a status later and this method stops compiling until you decide whether it is terminal.

2. API result types instead of null / exception soup

Return a sealed result from a service method when both success and expected failures are part of the contract:

sealed interface FetchUser permits Found, NotFound, Forbidden {}

record Found(User user) implements FetchUser {}
record NotFound(String userId) implements FetchUser {}
record Forbidden(String userId, String reason) implements FetchUser {}

static String render(FetchUser result) {
    return switch (result) {
        case Found(var user) -> user.displayName();
        case NotFound(var id) -> "missing:" + id;
        case Forbidden(var id, var reason) -> "denied:" + id + ":" + reason;
    };
}

Callers cannot “forget” Forbidden the way they forget to check a nullable or a sentinel.

3. Refactor an open interface that was never meant to be open

If every implementation already lives in one package and no plugin is supposed to add more, sealing documents reality:

// before: anyone can implement this
public interface DiscountRule {
    Money apply(Cart cart);
}

// after: closed set, still interface-shaped for dispatch
public sealed interface DiscountRule permits PercentOff, FlatOff, BuyXGetY {
    Money apply(Cart cart);
}

Behavior can stay on the interface as abstract methods; use pattern switch when external code needs to branch on which rule it has, without adding methods to the hierarchy (the expression-problem trade-off: sealed + switch favors adding operations; interface methods favor adding variants).

4. Records as the leaf data carriers

Keep carriers thin — validation in compact constructors, no service calls — as covered in the records post. Sealing answers “which carriers exist”; records answer “what data each carrier holds.”

Gotchas

non-sealed reopens the world

non-sealed is an intentional escape hatch for frameworks or libraries that need unknown subtypes. The moment you permit a non-sealed type, exhaustiveness over the sealed parent no longer covers “every possible runtime instance.” You will need a default (or to switch only on the still-closed leaves).

sealed interface Node permits Leaf, Branch, ExtensionPoint {}
record Leaf(String value) implements Node {}
record Branch(Node left, Node right) implements Node {}
non-sealed interface ExtensionPoint extends Node {} // third parties implement this

Same package / same module

Permitted subtypes cannot be scattered across arbitrary packages on the plain classpath. Plan package layout when you seal. In a JPMS module, subtypes can live in different packages of that module — still not in a dependent module.

Serialization, proxies, and frameworks

Reflection and serialization can surprise you with subtypes you did not list if something bypasses normal construction — treat sealed + records as a source-level contract, and keep framework mappings explicit. Same caution as records for JPA entities: prefer mutable entities internally and map to sealed result / DTO types at the edges.

Preview vs final

Do not copy blog posts that pass --enable-preview for sealed classes on modern JDKs: sealed types are standard on 17+. Pattern switch and record patterns are standard on 21+. If your CI still uses preview flags for these, upgrade the language level instead of normalizing preview forever.

default defeats the alarm

A default branch compiles even when a new permits entry arrives. Prefer listing every case for domain switches. Use default only when you intentionally accept an open leftover (often after non-sealed).

When not to use sealed types

Skip sealing when:

  • Third parties must add subtypes — SPI, plugins, JDBC drivers, open extension APIs.
  • The hierarchy is mostly shared mutable behavior — deep abstract classes with lots of protected state; sealing does not fix a muddy object model.
  • ORM-mapped entities — same reasons records struggle: proxies, empty constructors, mutation.
  • You only have one implementation — sealing a singleton hierarchy adds ceremony without exhaustiveness payoff.

If you need open extension and a closed core, seal the core and expose a separate extension interface — do not mark the whole API non-sealed by default.

Cheat sheet

sealed interface Name permits A, B, C {}
record A(...) implements Name {}
final class B implements Name {}
non-sealed class C implements Name {}  // reopens

switch (value) {
    case A(var x) -> ...
    case B b -> ...
    case C c -> ...
}

Sealed types: Java 17+ (JEP 409)
Pattern switch + record patterns: Java 21+ (JEP 441 / 440)
instanceof patterns: Java 16+ (JEP 394)
Same module (or same package without modules)
Good: closed domain variants, result types, ASTs
Avoid: plugin SPIs, JPA entities, unnecessary default arms

Do:

  • List every permit and every switch case so new variants break the build.
  • Prefer records as leaf carriers under sealed interfaces.
  • Keep sealed families in one module/package boundary you control.

Don’t:

  • Sprinkle non-sealed “just in case.”
  • Rely on a runtime IllegalStateException for “unreachable” open interfaces.
  • Add default on a fully sealed domain switch unless you mean to silence exhaustiveness.

Wrap-up

Sealed classes make “this set of subtypes is complete” a compiler-checked fact (Java 17 / JEP 409). Pattern matching for switch and record patterns turn that fact into readable, exhaustive dispatch (Java 21 / JEP 441 and JEP 440). Together they replace cascading instanceof, hand-rolled visitors for closed domains, and the quiet bugs that appear when someone adds a subtype and forgets a branch.

Start by sealing the hierarchy on 17 LTS. When you are on 21+, collapse the dispatch into one pattern switch. Keep records as the data carriers. Leave plugin surfaces open on purpose — and seal everything that was never meant to grow in the dark.

When nested patterns get noisy, unnamed variables and patterns let you ignore bindings with _. For a preview follow-up that extends instanceof and switch to primitives, see Primitive Types in Patterns.

Next optional step in the series Ignore unused bindings with _ instead of dummy names. Unnamed Variables and Patterns: Ignore What You Do Not Need With _