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); // goodrecord Customer(Optional<String> phone) {} // noOptional 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.
Discussion
Loading comments…