Week 9 Day 3 — Sealed Types and Pattern Matching
Goal
Today I want a closed hierarchy I can switch on exhaustively, instead of an open extends tree.
Main questions:
- What does
sealedpermit? - How does that differ from a normal interface?
- What is pattern matching for
instanceof? - How does
switchbecome exhaustive on a sealed type? - When is this better than a visitor or an
enum?
1. Sealed types
A sealed type names its permitted subtypes. Nothing else may extend or implement it.
public sealed interface PaymentResult
permits PaymentAccepted, PaymentRejected, PaymentPending {}
public record PaymentAccepted(String txnId) implements PaymentResult {}
public record PaymentRejected(String reason) implements PaymentResult {}
public record PaymentPending(String txnId) implements PaymentResult {}
Subtypes are final (records already are), sealed, or non-sealed. non-sealed opens that branch again — I use it rarely.
The compiler and the reader know the full set. That is the point of Week 3’s “inheritance for a closed domain.”
Memory sentence:
sealednames every subtype.switchcan be exhaustive.
2. Pattern matching for instanceof
Java 16/17:
if (result instanceof PaymentAccepted accepted) {
return accepted.txnId();
}
The binding accepted is in scope when the test succeeds. No cast. Java 21 can go further with record patterns:
if (result instanceof PaymentAccepted(String txnId)) {
return txnId;
}
I still prefer switch when I must handle every subtype.
3. Exhaustive switch
public String describe(PaymentResult result) {
return switch (result) {
case PaymentAccepted(String txnId) -> "ok " + txnId;
case PaymentRejected(String reason) -> "no: " + reason;
case PaymentPending(String txnId) -> "wait " + txnId;
};
}
No default if the hierarchy is sealed and I listed every permits. Adding a new permitted type breaks the compile until I handle it. That is the safety I wanted from the type system.
If I add default, I throw that exhaustiveness away. I only use default for non-sealed types or when I truly have a fallback.
4. Sealed vs enum vs open interface
| Tool | When |
|---|---|
enum | Fixed constants, no extra per-case data (or only a little) |
sealed + records | Closed set of shapes, each with its own fields |
| Open interface | Third parties (or I) will add implementations later |
PaymentGateway from Week 3 stays an open interface: Stripe, fake, next vendor. PaymentResult is closed: the service owns the outcomes.
A visitor pattern is the pre-sealed way to force exhaustiveness. Sealed + switch is the Java 17 version.
5. Spring connection
- Domain results as sealed types, mapped in
@ControllerAdviceor a small mapper to HTTP 200/402/202. - Do not seal Spring beans (
PaymentGateway). The container and tests must add implementations. - Jackson can serialize records in a sealed hierarchy with a type property (
@JsonSubTypes) if I need polymorphism on the wire. I often map to one response record instead.
6. Common traps
Trap 1: Sealing a Spring service interface.
Trap 2: default in a switch on a sealed type, then missing a new case at compile time.
Trap 3: non-sealed everywhere “for flexibility.” Then the hierarchy is open again.
Trap 4: Giant sealed tree as a substitute for a database status column. Enums still win for a single ordinal.
Trap 5: Forgetting permits when types are not in the same file. Same-file permitted types can omit permits in some cases; I write it anyway when they split files.
Practice Questions and Answers
Question 1
What does sealed buy me that an interface does not?
Answer:
The set of implementations is known to the compiler. I can switch without default and get a compile error when a new subtype appears. An open interface cannot do that; anyone may implement it.
Question 2
Sealed vs enum?
Answer:
Enum is a closed set of constants. Sealed + records is a closed set of data shapes. PaymentRejected carries a reason string; an enum constant cannot grow fields per case as cleanly.
Question 3
What is pattern matching for instanceof?
Answer:
if (x instanceof PaymentAccepted accepted) binds accepted already cast. On 21, instanceof PaymentAccepted(String txnId) deconstructs the record. I avoid the manual cast.
Question 4
Should OrderRepository be sealed?
Answer:
No. Repositories and gateways are extension points (tests, second vendors). I seal results and domain events I own, not the ports I want to fake.
Question 5
Why omit default in the switch?
Answer:
So a new permitted type is a compile failure, not a runtime surprise. default silences that.
Memory sentences
sealednames every subtype.switchcan be exhaustive.
Seal results I own. Keep ports (repositories, gateways) open.
Pattern matching binds and deconstructs. I do not cast by hand.