Skip to main content

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:

  1. What does sealed permit?
  2. How does that differ from a normal interface?
  3. What is pattern matching for instanceof?
  4. How does switch become exhaustive on a sealed type?
  5. 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:

sealed names every subtype. switch can 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

ToolWhen
enumFixed constants, no extra per-case data (or only a little)
sealed + recordsClosed set of shapes, each with its own fields
Open interfaceThird 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 @ControllerAdvice or 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

sealed names every subtype. switch can be exhaustive.

Seal results I own. Keep ports (repositories, gateways) open.

Pattern matching binds and deconstructs. I do not cast by hand.

Next: Week 9 Day 4 — Switch Expressions and Text Blocks