Low-level designCore7 min
Enums and sealed hierarchies
When something is always exactly one of a few known cases, say so in the types. An enum names the cases; a sealed hierarchy gives each case its own data. Either way the compiler knows every case, so a typo can't compile and a missing case is reported.
A kiosk reads payment results
Approved, declined, or check on the phone: each needs its own message.
1 · The situation
A cinema kiosk reads the card terminal
A cinema's ticket kiosk takes card payments. The terminal answers with one of three results: approved, with an authorization code to print on the ticket; declined, with a reason; or check, when the bank wants the customer to confirm on their phone, with a link. The first version stores the result as a status string and three optional fields:
class PaymentResult {
private final String status; // "approved", "declined" or "check"
private final String authCode; // only when approved
private final String reason; // only when declined
private final String checkUrl; // only when the bank asks for a check
PaymentResult(String status, String authCode, String reason, String checkUrl) {
this.status = status;
this.authCode = authCode;
this.reason = reason;
this.checkUrl = checkUrl;
}
String status() {
return status;
}
String authCode() {
return authCode;
}
String reason() {
return reason;
}
String checkUrl() {
return checkUrl;
}
}
class Kiosk {
String message(PaymentResult r) {
if (r.status().equals("aproved")) return "Enjoy the film! Code " + r.authCode();
if (r.status().equals("declined")) return "Card declined: " + r.reason();
return "Please confirm on your phone (" + r.checkUrl() + ")";
}
}Drawn from the code: one PaymentResult with every field any result might need.
2 · The change
One letter, and paid customers get the wrong screen
Three payments come in: one approved, one declined, one needing a check.
Please confirm on your phone () Card declined: insufficient funds Please confirm on your phone (bank.example/ok)
The approved payment is told to confirm on the phone, with an empty link.
class Kiosk {
String message(PaymentResult r) {
if (r.status().equals("aproved")) return "Enjoy the film! Code " + r.authCode();
if (r.status().equals("declined")) return "Card declined: " + r.reason();
return "Please confirm on your phone (" + r.checkUrl() + ")";
}
}Two weaknesses combine here:
- Strings accept anything.
"aproved"is as valid a string as"approved", so the typo compiles, runs, and falls through to the last branch. - Fields that only sometimes apply. Every result carries an auth code, a reason and a link, and the type doesn't say which are real. The approved payment fell into the branch that reads
checkUrl, which was empty, and nothing objected.
3 · An enum
A closed set of names
An enum lists every allowed value by name. Status.APPROVED can't be misspelled without a compile error, and a switch over an enum can be checked for a missing case:
enum Status { APPROVED, DECLINED, CHECK }
class Kiosk {
String message(PaymentResult r) {
return switch (r.status()) {
case APPROVED -> "Enjoy the film! Code " + r.authCode();
case DECLINED -> "Card declined: " + r.reason();
case CHECK -> "Please confirm on your phone (" + r.checkUrl() + ")";
};
}
}Enjoy the film! Code A7Q2 Card declined: insufficient funds Please confirm on your phone (bank.example/ok)
The approved payment prints its code.
Language | Enum | A missing case in a switch |
|---|---|---|
Java |
| A compile error in a |
C++ |
| A compiler warning ( |
Python |
| Reported by a type checker through |
JavaScript | A frozen object of constants | Only at run time, in a |
The enum fixes the first weakness but not the second. An approved result still carries an empty reason and an empty link, and nothing in the type stops the kiosk from reading them.
4 · A sealed hierarchy
Each case with exactly its own data
Make each case its own small type, carrying only its data, and close the set so no other case can exist. In Java that's a sealed interface that permits three records; C++ uses a std::variant of three structs; Python a union of three dataclasses; JavaScript objects told apart by a kind field. All four are forms of a tagged union: a value that is exactly one of a fixed set of shapes.
sealed interface PaymentResult permits Approved, Declined, NeedsCheck {}
record Approved(String authCode) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record NeedsCheck(String url) implements PaymentResult {}
class Kiosk {
String message(PaymentResult r) {
return switch (r) {
case Approved a -> "Enjoy the film! Code " + a.authCode();
case Declined d -> "Card declined: " + d.reason();
case NeedsCheck c -> "Please confirm on your phone (" + c.url() + ")";
};
}
}PaymentResult is sealed: Approved, Declined and NeedsCheck are its only implementations, and each holds only what it needs.
An approved result now has no reason or link to read by mistake: a.authCode() exists only on Approved, and the kiosk reaches it only in the Approved case.
5 · Exhaustive handling
Add a case, and the compiler lists what to update
Because the set is closed, the compiler knows every case, and checks that a switch covers them. Remove the NeedsCheck case from the kiosk and each language says so, at its own time:
Language | With one case missing | When |
|---|---|---|
Java | the switch expression does not cover all possible input values | Compile time |
C++ |
| Compile time |
Python | A type checker flags the | Before running |
JavaScript | The | At run time, when that case arrives |
That's what makes a new case safe to add. When the terminal later adds a canceled result, adding Canceled to the permitted list turns every switch that doesn't handle it into a compile error. Compare the string version, where a new status would have silently fallen through to the last branch. To keep this check, leave out a default branch where the language checks coverage: a default would quietly accept the new case.
The kiosk matches the result's case and reads that case's own data. There's no branch where the wrong fields could be read.
6 · Or polymorphism?
Closed cases, or open kinds
The page Polymorphism warned against switching on an object's type, and this page recommends a switch. The difference is whether the set is closed and checked:
Situation | Prefer | Why |
|---|---|---|
A fixed set of cases, handled in many different places | A sealed hierarchy or enum with exhaustive switches | Each place handles each case; a new case is flagged everywhere |
A set that grows, with one main behavior per kind | Polymorphism: each kind overrides a method | A new kind is one new class, and no caller changes |
Plain labels with no data | An enum | Simple, comparable, and cheap |
The practice checks follow the same line: a pattern switch over a sealed Java type isn't flagged, because the compiler already guards it, while a chain of type checks over an open set is.
7 · In practice
A vending machine's closed set of states
The vending-machine problem has a small, fixed set of states, a natural enum. Handle them with exhaustive switches, or give each state its own behavior with the State pattern when the behavior grows.
Design the vending machine with its states as a closed set.
Check yourself
4 questions
Takeaways
Remember this
- Strings for a fixed set of values accept typos and unknown values silently.
- An enum names a closed set of values; a switch over it can be checked for a missing case.
- A sealed hierarchy, or tagged union, gives each case only its own data.
- With a closed set and no default branch, adding a case makes the compiler list every switch to update.
- Use a sealed set for fixed cases handled in many places; use polymorphism for a set that grows.