Java

Sealed Classes and Pattern Matching Foundations

Executive Summary

Sealed classes in Java, and sealed interfaces, declare their permitted subtypes with the permits clause, standardized in JEP 409, and the compiler rejects every other extension. Each permitted subtype must itself be final, sealed, or non-sealed, so the tree’s openness stays explicit at every level. The payoff is closed-world reasoning: a switch expression over a sealed interface lists every permitted variant and needs no default. Also, adding a variant later fails every unhandled switch in the build. Record patterns from JEP 440 destructure variants inside case labels, and when guards refine matches. Use sealed hierarchies for domain events, result types, and state machines. However, keep public extension points open, because a sealed SPI is a wall where a door was promised. Never add default to a sealed switch: it absorbs future variants and deletes the guarantee you sealed for.

Why Sealed Classes in Java Matter

Before sealed types, every interface in Java was open by default. However, open interfaces have a hidden cost: nobody, including the compiler, knows all the implementations. Consider how code used to “handle” a family of event types. It was instanceof chains with casts, a default branch that swallowed unknown types, and the silent failure mode of adding a variant that nobody knows about. The interfaces article closed with a warning about that shape; sealing is the fix.

In short, sealing gives the compiler a closed world. When a type says sealed, the compiler knows the complete set of variants, which unlocks two things open hierarchies can never have: exhaustive checks without a default case, and a build that fails when you add a variant you forgot to handle. In practice, that converts an entire category of runtime surprises into compile errors, which is where you want all your surprises.

The permits Clause in Practice

In fact, the syntax is one keyword plus one list. Here is the complete working example this article builds on: a payment event family, modeled as a sealed interface with record variants:

sealed interface PaymentEvent permits PaymentReceived, PaymentFailed, RefundIssued { }

record PaymentReceived(String orderId, long amountCents, String method)
        implements PaymentEvent { }

record PaymentFailed(String orderId, String reason, boolean retryable)
        implements PaymentEvent { }

record RefundIssued(String orderId, long amountCents, String reason)
        implements PaymentEvent { }

Three rules govern that declaration, and the compiler enforces each one. Every permitted subtype must be final, sealed, or non-sealed: records count as final automatically, which is why they pair so well. Everything in the permits list must live in the same module, or the same package in an unnamed module. And if the subtypes are declared in the same file, the permits clause itself may be omitted and the compiler infers the list from the file.

The non-sealed keyword is the escape hatch: it reopens a branch of the tree below a sealed level. Use it deliberately and rarely, because everything under a non-sealed type is open again, permanently.

Exhaustive Switch Over Sealed Hierarchies

The payoff arrives in the switch. Pattern matching for switch, finalized in JEP 441, lets each case label match a type and bind it, and over a sealed hierarchy the compiler proves completeness:

static String describe(PaymentEvent event) {
    return switch (event) {
        case PaymentReceived r ->
                "received " + r.amountCents() + " for " + r.orderId();
        case PaymentFailed f ->
                "failed: " + f.reason() + (f.retryable() ? " (will retry)" : "");
        case RefundIssued rf ->
                "refund " + rf.amountCents() + " for " + rf.orderId();
    };   // exhaustive: no default needed, none allowed to be needed
}

Now the trap, and it is the one experienced developers fall into. Wrong code first:

// WRONG: default silently absorbs future variants
return switch (event) {
    case PaymentReceived r -> handle(r);
    case PaymentFailed f -> handle(f);
    case RefundIssued rf -> handle(rf);
    default -> "unknown event";   // next quarter's ChargebackDisputed lands here, silently
};

// RIGHT: no default. Add a variant and this file fails to compile,
// along with every other file that missed it.
return switch (event) { /* the three cases, exactly as above */ };

The delta: default is not harmless documentation; it is a catch-all that converts future compile errors into silent no-ops. Sealing bought you the exhaustive check, and default throws it away. The same discipline applies to the colon-form switch over sealed types: it never warns, so keep every sealed-type switch in the arrow expression form the control flow article recommended.

Record Patterns: Destructuring the Variants

In other words, type patterns bind a variant; record patterns open it. A case label can destructure a record into its components in place, and when guards refine the match. The same describe method, one level deeper:

static String describeDeep(PaymentEvent event) {
    return switch (event) {
        case PaymentReceived(String orderId, long amount, String method) ->
                "received " + amount + " via " + method + " for " + orderId;

        case PaymentFailed(String orderId, String reason, boolean retryable) when retryable ->
                "failed, will retry: " + reason + " for " + orderId;

        case PaymentFailed f ->
                "failed permanently: " + f.reason();

        case RefundIssued(String orderId, long amount, String reason) ->
                "refund " + amount + " (" + reason + ") for " + orderId;
    };
}

Read that as one sentence: match the shape, unpack the parts, and, when a guard says so, split one variant into two behaviors. Guarded cases must precede the general ones they refine, exactly as ordered here. If the style feels familiar from other languages, it should: this is the destructuring you may have seen in functional programming, arrived in Java through JEP 440 without ceremony.

How Real Systems Do This

In practice, production Java uses sealed hierarchies for exactly three shapes, and you have now seen all three in embryo. Domain events: PaymentEvent-style families that a message handler must dispatch exhaustively, where a missed event is a compliance bug. Result types: sealed interfaces with an Ok and an Err variant, which give error handling a typed shape instead of exceptions everywhere. State machines: closed sets of states where the transitions and handlers are the exhaustive switches you just wrote.

In my experience, the switch from open interfaces to sealed events is the refactor with the best ratio of effort to safety I have run. One service handled 14 event types with an instanceof ladder in three files. Meanwhile, two events fell through a default branch for months before anyone noticed the missing analytics. Sealing the family broke the build in exactly the 9 places that needed updating. As a result, the “silently unhandled event” category ceased to exist.

The pattern also travels well conceptually. Open for extension, closed for modification is the design rule the inheritance article quoted. In fact, sealing is what makes “closed” a fact the compiler checks rather than a hope you document.

Decision Framework

Seal when the answers line up like this.

  1. Is the set of variants fixed, at least per release? Sealed. Genuinely open, like a plugin API? Open interface, no permits.
  2. Are the variants mostly data with little behavior? Records implementing a sealed interface: the algebraic data type shape.
  3. Do handlers need to be exhaustive, with the build enforcing it? In fact, that requirement is the reason to seal.
  4. Do some branches need to stay open while others close? non-sealed on that branch, consciously, with a comment saying why.
  5. Are variants churning weekly? Sealing makes every addition a deliberate break. So, if churn is routine, consider an enum or revisit whether the set is really fixed.
  6. Can all subtypes live in the same module or package? If not, sealing cannot apply there at all.

When NOT to Use This

  • Do not seal public extension points. Frameworks and customer-facing SPIs promise implementors freedom; permits is a wall where the contract promised a door.
  • Do not seal types whose subtypes must live across packages or modules you do not own. The compiler rejects the hierarchy, and the design was fighting the language.
  • Do not seal for the aesthetic. If no code ever switches over the family and the variant set has no meaning to close, sealing adds ceremony without a single caught bug.

Common Mistakes

  • Adding default to a sealed switch. In other words, it converts future “you forgot me” compile errors into silent no-ops, deleting the entire value of sealing.
  • Assuming exhaustiveness without sealing. Over an open interface, the compiler demands a default; only the permits list proves the world is closed.
  • Forgetting the subtype rule: permitted types must be final, sealed, or non-sealed. The compiler error you get is a design question, not an obstacle.
  • Leaking subtypes across packages: permits members must colocate. Splitting them across modules fails the build with a confusing message about accessibility.
  • Handling null carelessly: the classic switch throws on a null selector, while the pattern form can match case null explicitly. So, decide which contract you want at the boundary.
  • Treating non-sealed as “sealed with exceptions”: every type below the non-sealed branch is open forever, so one careless keyword reopens a whole subtree.

Key Takeaways

  • Sealed types list their permitted subtypes with permits, and the compiler rejects all other implementations.
  • Permitted subtypes must be final, sealed, or non-sealed. Also, records qualify automatically, making records plus a sealed interface the standard variant modeling shape.
  • Switch expressions over sealed hierarchies are exhaustive without default, and adding a variant breaks every switch that missed it at build time.
  • Never add default to a sealed switch: it silently absorbs future variants and defeats the purpose of sealing.
  • Record patterns destructure variant components in case labels, and when guards refine matches.
  • Seal domain events, result types, and state machines; keep public SPIs and plugin interfaces open.
  • non-sealed reopens a branch permanently; use it rarely and on purpose.

FAQ

What is a sealed class in Java?

A class or interface that declares exactly which types may extend or implement it, via the permits clause. The compiler enforces the list, which lets it prove that code handles every permitted variant.

What is the permits clause in Java?

The explicit list of allowed subtypes after the sealed keyword: sealed interface PaymentEvent permits PaymentReceived, PaymentFailed. When subtypes live in the same file, the list may be omitted and inferred.

Can a switch over sealed types omit the default case?

Yes, and it should. Exhaustiveness comes from the permits list: cover every permitted variant and the compiler accepts the switch without default. Adding default hides future variants silently, so omit it.

What are record patterns in Java?

Case labels that destructure a record into its components as part of the match: case PaymentReceived(String orderId, long amount, String method). Guards with when refine matches on the unpacked values.

Should every interface be sealed by default?

No. Seal variant sets you control and want to dispatch exhaustively: events, results, states. Public extension points, plugin APIs, and interfaces customers implement must stay open.

Conclusion

Sealing is the last piece of modern Java’s modeling toolkit: enums close fixed sets of values, records fix the shape of data, and sealed hierarchies close families of types so the compiler verifies every handler. In short, together they turn “the compiler cannot help me here” into “the build knows I forgot ChargebackDisputed.”

Next, the Part 2 practice build applies everything: you will design a library system domain with classes, records, enums, a sealed hierarchy, and the packages to hold them.

Open for extension, closed for modification, and now the compiler can check the second half.

Last updated on 19 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *