Modelling fixed choices with enums
Open in the interactive app: practice, quiz, mock interview →In one minute
- An enum is a type with a fixed, named set of values: order statuses, card suits, log levels, HTTP methods. It replaces magic strings and integers with names the compiler or runtime can check, so
"canceled"versus"cancelled"becomes an error instead of a silent new state. - The big win is exhaustiveness. When code switches over an enum, a good toolchain tells you about every place that forgot the new member you just added. Rust, Swift, Kotlin and TypeScript (with a
nevercheck) do this; Python and Go need a test or a linter. - Enums live in three places at once: in memory as a small integer or singleton object, on the wire as a string or number, and in the database as a column. The in-memory form is free to change; the wire and storage forms are a contract.
- Serialise by name, not by position. Persisting ordinals means inserting a member in the middle silently renames every stored row. Parse at the edge, strictly, with aliases for legacy spellings and a clear error for unknown values.
- Agents scatter string literals (
if status == "shipped") and adddefault:branches that swallow new members. Review asks for one enum, one parse function, one transition table and a test that every member is covered.
How it works
Strings everywhere
An order service stored status as a free-form string. Two years later the database contains paid, Paid, canceled, cancelled and cancel, because six services wrote it with their own spellings. The fulfilment worker checks status == "paid" and skips every Paid order.
An enum gives the set a single definition:
class OrderStatus(Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REFUNDED = "refunded"Inside the program there is now exactly one PAID, and a typo like OrderStatus.PIAD fails at the point of use. Strings still arrive at the edges (HTTP bodies, queue messages, old rows), so one parse_status function turns them into members: trim, lowercase, map known legacy spellings such as canceled, and reject everything else with an error that names the bad value. Everything past the edge handles members, never raw strings.
Transitions as a table
Order statuses are not just labels; they move. Pending orders can be paid or cancelled, paid orders can ship, be cancelled or be refunded, and delivered orders can be refunded. Encode that as data, one entry per member:
TRANSITIONS = {
PENDING: [PAID, CANCELLED],
PAID: [SHIPPED, CANCELLED, REFUNDED],
SHIPPED: [DELIVERED],
DELIVERED: [REFUNDED],
CANCELLED: [],
REFUNDED: [],
}Now transition(current, target) is a lookup, illegal moves fail with a message naming both ends, "terminal" is derived (no outgoing edges), and the whole lifecycle fits on one screen. When each status needs real behaviour (different validation, side effects, timers), the State pattern from its own lesson gives each status a class; for a lifecycle that is mostly "which moves are legal", an enum and a table are clearer and cheaper.
Exhaustiveness
Adding ON_HOLD must force every piece of code that reasons about statuses to decide what to do with it. Languages give that guarantee in different ways:
- TypeScript: model statuses as a union of string literals (
type OrderStatus = "pending" | "paid" | ...). ARecord<OrderStatus, ...>must list every key, and aswitchwhosedefaultassigns the value toneverfails to compile when a case is missing. - Rust, Swift, Kotlin:
match,switchandwhenover an enum must be exhaustive, or the compiler rejects them. - Java: a
switchexpression over an enum (Java 14 and later) must cover every constant unless it has adefault. - Go: constants declared with
iotaare not a closed set; any integer converts to the type, andswitchis never checked. Theexhaustivelinter fills the gap, and a test that iterates over anAllslice catches table gaps. - Python:
matchdoes not check exhaustiveness, but a type checker such as mypy or pyright can withassert_never, and a one-line test that every member has a table entry covers the rest.
The common trap in every language is default:. A default branch tells the compiler "I have handled everything else", so a new member falls into it silently. Prefer listing every member and keeping the default only for genuinely open sets.
Parsing and serialisation
The wire form is a contract with other services and with your own past. Use stable lowercase names, not ordinals, and not the language's default representation if that might change. Parse strictly, in one place, and decide deliberately what an unknown value means. For a command ("set status to X"), reject it. For data from a newer producer (a status added by a service that deployed first), a tolerant reader may map it to an explicit UNKNOWN member and skip the record, so an old consumer does not crash on a new value. Protocol Buffers bakes this in: proto3 enums must start with a zero value conventionally named *_UNSPECIFIED, and unrecognised numbers are preserved rather than rejected.
Reviewing agent-written code
Asked to "add an on-hold status", an agent will often grep for "shipped", add "on_hold" beside it in a few places, miss the others, and add a default branch where the compiler would have complained. Point it at the single enum, the parse function and the transition table, and ask for a test that iterates over every member. In review, check for raw string comparisons outside the parse function, default branches over closed enums, ordinals written to storage, and whether consumers deploy before producers start emitting the new value.
The same shape elsewhere
An enum with a parse function is the trust but verify shape: outside data is untrusted until one gatekeeper validates it into a closed set. Kernels validate syscall numbers against a table and SQL CHECK (status IN (...)) constraints do it at the storage layer, each deciding spellings, aliases and the unknown-value policy.
Down the stack
Layer 1The contract
An enum promises a closed set: every value of the type is one of the declared members, members are distinct, and comparison is by identity or by a small integer. The parse function promises that every accepted input maps to exactly one member, that known aliases map to their canonical member, and that every other input fails with an error naming it. Serialisation promises that parse(to_wire(m)) == m for every member and that the wire name of a member never changes once published. The transition table promises an entry for every member.
Layer 2The mechanism
Python's Enum is built by the EnumType metaclass when the class body runs. Each member is a singleton instance created once, stored in _member_map_ (by name) and _value2member_map_ (by value), so OrderStatus("paid") is a dictionary lookup and is comparison is safe. Members pickle by name. StrEnum (Python 3.11) makes members real strings, which compares equal to "paid" and also lets raw strings leak back in.
TypeScript numeric enums compile to an object with a reverse mapping (Status[Status["Paid"] = 1] = "Paid"), so Status[1] is "Paid". String enums have no reverse mapping, const enum is inlined at use sites and breaks under single-file compilers, and the --erasableSyntaxOnly flag added in TypeScript 5.8 rejects enums entirely so code can run under type-stripping. A union of string literals erases to nothing, which is why many codebases prefer it.
Go's iota numbers constants 0, 1, 2 within a const block, and the stringer tool generates a String() method that slices one concatenated string using an offsets array (_Status_index), so there is no map and no allocation. Implementing encoding.TextMarshaler and TextUnmarshaler makes encoding/json read and write the names. Java's EnumSet is a bit vector in a single long for up to 64 constants, and Rust stores a discriminant plus the largest payload, using spare bit patterns so Option<&T> is the size of a pointer.
Layer 3The machine
A plain enum is a small integer: one byte in Go with uint8, four bytes in C by default, and a pointer to a shared singleton in Python and Java. Comparing two members is one instruction, against a memcmp for strings (a few nanoseconds for short strings, more for long ones). A switch over dense values compiles to a jump table: a bounds check and an indirect jump, about 1 to 3 ns, or under 1 ns when predicted. A transition table over six statuses fits in six bytes as bitmasks, allowed[from] & (1 << to), one load and one AND.
Storage is where size adds up. A Postgres enum column is 4 bytes per row and sorts in declaration order; a text column holding "delivered" is 10 bytes (1-byte header plus 9 characters) and sorts alphabetically. Across 500 million order rows that is roughly 3 GB of heap and index space. The catch is change: Postgres can add enum values with ALTER TYPE ... ADD VALUE but cannot drop one without rebuilding the type, so fast-changing sets often use a smallint or text column with a CHECK constraint or a reference table instead. On the wire, a protobuf enum is a varint (1 byte for values under 128), while JSON carries the name, a few bytes more and far easier to debug.
Why it's built this way
- Free-form strings drift in spelling across writers, so the set of legal values is declared once as a type.
- Programs change and sets grow, so the compiler or a test must find every place that does not handle a new member, which only works if the set is closed and switches avoid defaults.
- Memory representation is an implementation detail but storage and wire formats outlive the code, so serialise stable names, not positions.
- Other systems send values you do not recognise, so parsing happens at one edge with an explicit policy for unknowns.
- Lifecycle rules are data about the set, so they live in a table keyed by member rather than in conditionals spread across services.
Diagram
stateDiagram-v2 [*] --> pending pending --> paid pending --> cancelled paid --> shipped paid --> cancelled paid --> refunded shipped --> delivered delivered --> refunded cancelled --> [*] refunded --> [*]
Worked example
A retailer's Java order service mapped OrderStatus with JPA, whose default @Enumerated mode is ORDINAL, so the status column held 0 for PENDING, 1 for PAID, and so on. A developer added AUTHORIZED between PENDING and PAID, because that is where it belongs in the lifecycle. The deploy shifted every later member up by one: 1.4 million paid orders now read as authorised, shipped orders read as paid, and the fulfilment job started re-shipping about 2,000 orders before an alert on duplicate tracking numbers fired 40 minutes later.
Recovery took two days of reconciling writes from the audit log. The lasting fix: columns became text with a CHECK constraint listing the wire names; the enum gained an explicit wire field and one parse method with aliases for legacy spellings; a test asserted every member's wire name against a checked-in list, so reordering fails CI; and adding a status became a two-step rollout, consumers first. Storage grew by about 1.2 GB across 150 million rows, which the team judged cheap insurance.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Raw strings or ints | No ceremony; any value accepted | Typos and drift; no exhaustiveness | Prototypes, truly open sets |
| Language enum, serialised by name | Closed set; readable wire format; safe reordering | A few bytes more than ordinals | Most business enums |
| Enum serialised by ordinal | Smallest storage and wire size | Reordering or inserting corrupts data | Never for persisted data, except with explicit fixed numbers (protobuf) |
| String literal union (TypeScript) | Zero runtime cost; exhaustive checks; erasable | No runtime list unless declared separately | TypeScript code, especially with type-stripping |
| Sum types with payloads (Rust, Swift) | Each variant carries exactly its data | Harder to store in a flat column | Rich domain states, parser results, errors |
| Reference table in the database | Values changeable without a deploy; labels editable | No compile-time checks; joins | Sets owned by operations, not by code |
Pitfalls in production
- Ordinals persisted to a database or file, so adding a member in the middle remaps every row. Fix: store names or explicit fixed numbers, and test the mapping against a checked-in list.
- A
defaultbranch that swallows new members, so a new status is silently treated as some old one. Fix: list every member, and enable exhaustive checks (never, the Goexhaustivelinter, mypy'sassert_never). - A producer emitting a new value before consumers understand it, crashing strict parsers. Fix: deploy consumers first, and use an explicit
UNKNOWNmember where tolerance is right. - Go's zero value treated as a real member, so an unset field reads as
Pending. Fix: reserve 0 for an invalid or unspecified member and reject it when serialising. - Comparing members with raw strings (
status == "paid"), which is always false for a plain PythonEnumand quietly true for aStrEnum. Fix: compare members to members and keep strings at the edge. - Renaming a member's wire value in a refactor. Fix: separate the code name from the wire name, and treat the wire name as a published API.
Leadership lens
- Decide who owns the set. If product or operations add values weekly, a reference table beats a code enum; if code must react to each value, a code enum with exhaustive checks is safer.
- Treat adding an enum value as a compatibility event with a rollout order: readers first, writers second, as with any schema change.
- Ban ordinal persistence in review checklists and lint rules. It is a cheap rule that prevents an expensive class of incidents.
- Give agents the shape explicitly: one enum, one parse function at the edge, one transition table, a test that iterates over all members, no
defaulton closed sets.
Check yourself
Why is serialising an enum by ordinal dangerous even if nobody ever deletes a member?
Show answer
Inserting a member anywhere except the end shifts the ordinals of every later member, so stored numbers now decode to different members without any error.
Why does a
default:branch weaken an exhaustive switch over an enum?Show answer
It tells the compiler every remaining case is handled, so a newly added member falls into the default silently instead of producing a compile error at each switch that must decide what to do with it.
A consumer receives a status value it does not recognise. When should it reject the message and when should it map it to
UNKNOWN?Show answer
Reject when the value is a command or input you must act on correctly; map to
UNKNOWNand skip when the data comes from a newer producer and failing would block unrelated processing.Why might you derive "terminal" from the transition table instead of storing it as a separate flag?
Show answer
A terminal status is one with no outgoing transitions; deriving it keeps one source of truth, so adding a transition cannot leave a stale flag behind.
Watch
- Why do we keep reinventing enums? (rats159): how languages from C to Rust handle enums and why the designs differ.
- How to Overcome the Lack of Enums in Go (by using iota) (Ones and Zeros): typed constants,
iotaandString()methods as Go's enum idiom.
Free resources
- Enum HOWTO (Python docs): members as singletons, value lookup,
StrEnum,Flagand how the metaclass builds them. - Enums (TypeScript Handbook): numeric and string enums, reverse mappings and
const enumcaveats. - Iota (Go wiki): how
iotanumbers constants and the patterns built on it. - stringer (Go tools): generating
String()methods for integer constants without maps or allocation. - Enumerated types (PostgreSQL docs): storage size, ordering and the limits on changing enum types.
- Language guide, enums (Protocol Buffers): zero-value defaults and how unknown enum values are preserved across versions.