Evolutionenumsforward compatibilityunknown valuesclosed vs open

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.

▶ Run the labFollow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When the server returns a value this client has never heard of, what does the contract say the client must do?
Consumers
Every client with a `switch` statement: the mobile build compiled against last year's enum living 18 more months in the field, the partner SDK generated from an old spec, the analytics pipeline that groups by status string.
The promise
An extensible-enum contract lets the provider add values without a coordinated deploy, because every client was built — and contractually required — to handle values it does not recognize.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

Client code the closed-set contract invited
1switch (user.status) {
2 case 'active': return <ActiveBadge />
3 case 'inactive': return <InactiveBadge />
4 default:
5 // "unreachable" — the enum has 2 values
6 throw new Error(`bad status: ${user.status}`)
7}
8// server adds 'suspended' → every user list
9// containing one suspended user now crashes
10// in a build you cannot hotfix for months
Forward-compatible handling the contract required
1switch (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.

Three designs for a server-returned enum
DesignAdding a value is…Old-client experienceReach for it when
Closed setA breaking change, by declarationNever surprised; the set they compiled against is completeSmall, semantically complete sets that genuinely cannot grow
Open set + unknown-handling clauseSafe, routineSees the raw new value; degrades per documented guidanceStatuses, event/webhook types, reason codes — anything tracking a live business process
Open + other catch-allSafe; server maps new cases to other for old media typesSees a stable known value, loses distinction between new casesOld 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.

The four-sentence clause, written for a real field
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: hide loses records silently, default: treat-as-X makes 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.

Compatibility Analyzer
Eight proposed changes to a shipped API. Which break existing clients?
Add optional response field `nickname`
Rename response field `status` → `state`
Change `amount` from number to string ("19.99")
Server starts returning new enum value `suspended`
New optional request parameter `sort`
Make optional request field `currency` required
Return 422 instead of 400 for validation failures
Add a new endpoint `GET /projects/{id}/activity`

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → contract: documents status: active | inactive with no extensibility clause; the docs read as a closed set because nothing says otherwise.
  2. 2
    Consumers → clients: write exhaustive switches, UI maps and CHECK constraints against the two values — exactly what the docs invited.
  3. 3
    Product → API: suspension ships; suspended starts appearing in responses via a routine deploy nobody flagged as breaking.
  4. 4
    Old clients → production: mobile builds crash rendering user lists; a partner dashboard silently drops suspended accounts; one integration defaults unknowns to active and keeps granting access.
  5. 5
    Team → incident review: the deploy "changed nothing in the schema" — the breaking change is invisible to every structural diff tool they had.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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]]).
What it costs
  • • 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.

Misconceptions

Claim
“Adding an enum value is an additive change, and additive changes are safe.”
Reality
Additive to the schema, breaking to behavior. Clients encode closed-set assumptions in switches, lookups and constraints; a new returned value violates every one of them unless the contract required unknown-handling from day one.
Claim
“Clients should just be written defensively — unknown values are their problem.”
Reality
Defensive against *what*, in favor of *what*? Without per-field guidance, one client hides the record, another treats it as active, and both were "defensive". Safe degradation is a semantic decision only the contract author can make.
Claim
“We use protobuf, so enum evolution is handled.”
Reality
Proto3 keeps deserialization from failing and reserves the zero value — the plumbing. What the client *does* with an unrecognized state, and how the state machine's transitions evolve, are still contract decisions protobuf cannot make for you.

Apply it