Enum Evolution: The New Value That Broke Old Clients
You add suspended to a status enum — additive, surely safe. Every old client that switched exhaustively over the closed set now throws, hides the record, or worse, treats it as active. Enums are the sharpest edge of compatibility, and the fix is a contract clause, not a code change.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Anatomy of the break
The provider's view: status was active | inactive, the product now needs suspended, adding an enum value changes no field, no type, no endpoint — it goes out as a routine deploy. The consumer's view: their code was written against a *closed set*, because that is what the word "enum" told them. Exhaustive switches, lookup tables keyed by value, database CHECK constraints mirroring the enum, UI mappings from value to badge color — every one of these encodes the assumption that the set is complete.
What happens next depends on the client's failure mode, and all of them are bad. The strict client throws on deserialization and the whole response is lost — a list endpoint returning one suspended user takes down the entire user list. The defensive client hits default: and hides the record, so suspended users silently vanish from admin screens. The dangerous client falls through to a *default value* — and if that default is active, a suspended account just kept its access. The same shape recurs on the wire: a payment state enum gaining disputed, a webhook event type gaining a new event, an error code enum gaining a new code (see An Error Taxonomy Clients Can Branch On).
The uncomfortable verdict: adding a returned enum value is additive for the schema and breaking for behavior — unless the contract said, from day one, that the set is open. This is the single most common "safe" change that is not, which is why it gets its own lesson rather than a row in Backward Compatibility: The Real Rules.
1switch (user.status) {2 case 'active': return <ActiveBadge />3 case 'inactive': return <InactiveBadge />4 default:5 // "unreachable" — the enum has 2 values6 throw new Error(`bad status: ${user.status}`)7}8// server adds 'suspended' → every user list9// containing one suspended user now crashes10// in a build you cannot hotfix for months1switch (user.status) {2 case 'active': return <ActiveBadge />3 case 'inactive': return <InactiveBadge />4 case 'suspended': return <SuspendedBadge />5 default:6 // contract: unknown statuses WILL appear.7 // Render safely, never guess semantics.8 log.info('unknown user.status', user.status)9 return <NeutralBadge label={user.status} />10}The difference is not defensive programming taste — it is which contract each client was written against. The good version exists only if the API documented status as extensible and told clients what "safe" means for an unknown value (here: display neutrally, never treat as active).
Open, closed, or open-with-other: a per-field decision
Not every enum should be open. Enums the *client sends* (request enums) are naturally closed from the client's perspective — a client cannot send a value it does not know — and the server validates against its current set, rejecting unknowns with a clear Validation Errors: Feedback, Not Verdicts response. The evolution question is almost entirely about enums the *server returns*.
For returned enums, you have three honest designs. Closed: the set is frozen; adding a value is declared a breaking change and routed through Versioning: What a Version Even Promises or a new field. Right for tiny, genuinely complete sets (currency_side: debit | credit). Open: the set grows; clients are contractually required to handle unknowns, with per-field guidance on what safe degradation means. Right for anything reflecting a business process — statuses, event types, reasons — because business processes grow states. Open with `other`: the server itself maps rare or new cases to a documented catch-all, often paired with a free-text detail field. Right when you want old clients to receive a *stable* value rather than an unknown one — at the cost that they cannot distinguish new cases.
Protobuf made a version of this choice for you: proto3 enums decode unknown wire values into an "unrecognized" representation instead of failing, and the convention of a zero-valued _UNSPECIFIED entry exists precisely because absent and unknown must be distinguishable (see gRPC: Schema, Codegen and Streams). JSON APIs have no such floor — the openness rule exists only if you write it.
| Design | Adding a value is… | Old-client experience | Reach for it when |
|---|---|---|---|
| Closed set | A breaking change, by declaration | Never surprised; the set they compiled against is complete | Small, semantically complete sets that genuinely cannot grow |
| Open set + unknown-handling clause | Safe, routine | Sees the raw new value; degrades per documented guidance | Statuses, event/webhook types, reason codes — anything tracking a live business process |
Open + other catch-all | Safe; server maps new cases to other for old media types | Sees a stable known value, loses distinction between new cases | Old clients must keep making decisions on the value (billing, access) and "unknown" is not an acceptable input to those decisions |
Writing the clause, and evolving the machine behind the enum
The clause that prevents all of this costs four sentences in the field's documentation, and it must exist *before* the first client ships — retrofitting it is asking every existing consumer to change, which is exactly the migration the clause was meant to avoid. It needs four parts: the set is extensible; unknown values will appear without a version bump; what safe handling means for this field specifically; and where new values are announced. "Handle it gracefully" is not guidance — for an account status, safe means "treat as neither active nor deleted; do not grant access; display neutrally", and that sentence is the actual contract.
Status enums usually front a state machine, and adding a value means adding a *state* — so the transition rules must evolve with it (see Resources Have State Machines). Old clients do not just render suspended; they decide which buttons to show, and a client that offers "deactivate" on a state it does not understand may be issuing transitions the machine now rejects. The contract answer: servers reject invalid transitions with a Status Codes Clients Can Branch On-honest 409 regardless of client vintage, and clients derive available actions from the API (an available_actions array or HATEOAS-ish links) rather than hardcoding action rules per status. That moves state-machine evolution entirely to the server — the one place you can deploy.
Finally, test the openness claim. A contract test that returns status: "zz_test_unknown" to each SDK and asserts graceful handling turns the clause from documentation into a verified property (see Testing the Contract, Not Just the Code and SDK Design: The Contract's User Interface) — generated SDKs in strict languages are the usual offenders, mapping enums to closed native types that throw on novelty.
status (string, extensible enum)
Current values: active · inactive · suspended
1 This set is extensible: new values MAY appear in any release
without a version change.
2 Clients MUST tolerate unknown values.
3 Safe handling for unknown status: treat the account as neither
active nor deleted — do not grant access, do not delete local
data; display the raw value neutrally.
4 New values are announced in the changelog ≥ 30 days before use.Key points
- Adding a server-returned enum value is additive for the schema and breaking for behavior — unless the contract declared the set open before the first client shipped.
- Old-client failure modes are all bad: strict deserializers lose the whole response,
default: hideloses records silently,default: treat-as-Xmakes security decisions on a guess. - Request enums are validated server-side and can grow freely; the hard problem is exclusively values the server returns.
- Choose per field: closed (growth = versioned break), open (unknown-handling clause), or open-with-
other(stable value for old clients, lost distinction). - The unknown-handling clause must say what safe degradation means for *this* field — "handle gracefully" is not a contract.
- Status enums front state machines: evolve transition rules server-side and let clients derive available actions from the API instead of hardcoding them.
Compatibility Analyzer
Change the contract and observe which guarantee moves.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → contract: documents
status: active | inactivewith no extensibility clause; the docs read as a closed set because nothing says otherwise. - 2Consumers → clients: write exhaustive switches, UI maps and CHECK constraints against the two values — exactly what the docs invited.
- 3Product → API: suspension ships;
suspendedstarts appearing in responses via a routine deploy nobody flagged as breaking. - 4Old clients → production: mobile builds crash rendering user lists; a partner dashboard silently drops suspended accounts; one integration defaults unknowns to
activeand keeps granting access. - 5Team → incident review: the deploy "changed nothing in the schema" — the breaking change is invisible to every structural diff tool they had.
- Whole responses are lost to strict deserializers: one novel value in one element takes down an entire list rendering in the field for months.
- Records vanish or are misclassified silently — the treat-as-active failure is an access-control incident wearing a compatibility costume.
- The provider learns to fear its own enums: new business states get encoded as boolean side-fields (
is_suspended) to avoid touching the enum, and the model rots.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Declare every server-returned enum open or closed explicitly at design time; default to open for anything reflecting a business process, and attach the four-part unknown-handling clause.
- • Give per-field safe-degradation guidance that a client author can implement without judgment calls ('do not grant access; display neutrally').
- • Keep clients out of the transition business: servers enforce state-machine rules and reject invalid transitions with `409` for every client vintage; clients read available actions from the response.
- • Verify forward compatibility mechanically: contract tests inject unknown values against every SDK; generated-SDK enum mappings are checked for non-throwing unknown handling.
- • Log and count `unknown enum value` events client-side (SDKs should emit them) — a rising count segmented by SDK version shows exactly which consumers will hurt when the value ships for real.
- • After introducing a value, watch error rates and support volume segmented by client build age; crashes clustered on old builds are the closed-set assumption failing in the field.
- • Track how often `other`/catch-all values are returned per consumer media-type version — a growing share means old clients are losing more and more distinction and the migration deadline should move up.
- • With the clause in place, enum growth is a changelog entry plus a waiting period — the cheapest evolution any contract change will ever get.
- • A closed enum that needs to grow evolves by new field (`status_v2` or a finer `sub_status`) or by version, never by silently redefining the promise as open.
- • Retiring a value is the mirror problem: stop *producing* it long before removing it from the documented set, since consumers hold tables and constraints keyed by it (see [[removing-fields]]).
- • Open enums forfeit exhaustiveness checking — the compiler can no longer tell client authors "you forgot a case", which was genuinely useful; the discipline moves into the mandatory default arm.
- • Open-with-`other` keeps old clients decision-capable but blinds them to novelty; systems that must react to the new state (fraud tooling, billing) cannot live behind the catch-all.
- • The 30-day announcement window slows product launches that need a new state today; the alternative — shipping unannounced — spends consumer trust instead.