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.

Watch it evolve1 / 8
The problem

A kiosk reads payment results

Approved, declined, or check on the phone: each needs its own message.

80
Start
Stage 1
Stage 2
Final

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:

A status string and fields that only sometimes applyJava
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() + ")";
    }
}
PaymentResult− status: String− authCode: String− reason: String− checkUrl: String~ status(): String~ authCode(): String~ reason(): String~ checkUrl(): StringKiosk~ message(PaymentResult): String

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.

The kiosk compares against a misspelled statusJava
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:

Statuses as an enum, handled by an exhaustive switchJava
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

enum, a class with one instance per value

A compile error in a switch expression

C++

enum class, named integer values

A compiler warning (-Wswitch), an error with -Werror

Python

enum.Enum

Reported by a type checker through assert_never()

JavaScript

A frozen object of constants

Only at run time, in a default that throws

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.

Three cases, closed, each with its own dataJava
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() + ")";
        };
    }
}
«interface»PaymentResult«record»Approved− authCode: String«record»Declined− reason: String«record»NeedsCheck− url: StringKiosk~ message(PaymentResult): String

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++

std::visit fails to compile without a handler for each alternative

Compile time

Python

A type checker flags the assert_never() call

Before running

JavaScript

The default branch throws

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

1. Why did the approved payment show the phone-check message?
2. What does a sealed hierarchy add over an enum here?
3. The terminal adds a Canceled result. In the Java sealed version, what happens to the kiosk's switch?
4. Why leave out a default branch in an exhaustive switch?

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.