Modern Java in Production
ID
Lesson 2 of 9 · 13m

Sealed types and a switch that cannot rot

Why a closed set is worth telling the compiler about, what exhaustiveness actually buys you, and when sealing is a maintenance tax instead.

Most of modern Java’s best additions share one property: they let the compiler check something it previously could not.

Sealing states the set

public sealed interface PaymentResult {
record Settled(String reference, Instant at) implements PaymentResult {}
record Declined(String code, String reason) implements PaymentResult {}
record Pending(Instant retryAfter) implements PaymentResult {}
}

sealed means: these implementations and no others. Nesting them inside the interface makes the permitted set implicit; declaring them elsewhere requires an explicit permits clause and the implementations must be in the same module (or the same package, unnamed module).

Exhaustiveness is the whole point

String describe(PaymentResult result) {
return switch (result) {
case Settled s -> "settled " + s.reference();
case Declined d -> "declined: " + d.code();
case Pending p -> "retry after " + p.retryAfter();
};
}

No default, and it compiles. The compiler knows the set is closed and that every member is handled, so the switch is exhaustive — a default here would be dead code, and some compilers will say so.

Now add a fourth outcome tomorrow:

record Refunded(String reference) implements PaymentResult {}

Every switch over PaymentResult that lacks a Refunded case stops compiling. That is not an inconvenience, it is the feature. The alternative is a default branch that quietly treats a brand-new state as one of the old ones — and a refund reported as “declined” is the kind of bug that reaches a customer before it reaches a test.

Patterns, and guards where they help

Pattern matching destructures in the same breath:

switch (result) {
case Declined(String code, String reason) when code.startsWith("5") ->
retryLater(reason);
case Declined(String code, _) -> giveUp(code);
case Settled s -> receipt(s);
case Pending p -> schedule(p.retryAfter());
}

Two things to know about the guard. Order matters: cases are tried top to bottom, so the guarded case must come before the unguarded one that would also match. And a guard does not weaken exhaustiveness checking as long as some unguarded case covers the type — case Declined(String code, _) is what keeps this switch exhaustive.

When not to seal

Sealing is a promise that the set is closed. Make it where that is true:

Closed, so seal it Open, so do not
what can happen to a payment the countries you support
the states of a workflow the kinds of report a user can run
the shape of a parse result plugin implementations

A sealed interface over “the payment providers we integrate with” reads well right up to the Monday you add one and touch nineteen files. That is a maintenance tax paid for no compiler guarantee you needed — nothing was going to silently mishandle a provider, because the code that dispatches on provider was always going to be one lookup.

Optional is a return type

Related, and the same instinct — put the fact in the signature:

Optional<Customer> findByEmail(String email); // good
record Customer(Optional<String> phone) {} // no

Optional as a field is the recognised anti-pattern: it is not serialisable, it adds a wrapper to every access, and it lets a field be in three states (absent, present-but-empty, present) where two were the point. As a parameter it is usually two methods wearing one signature. As a return type it does its job: “there might be nothing here” becomes part of the contract instead of a comment nobody reads.


The longer version, with the cases where these patterns bite: Modern Java Production Patterns.