Quick answer: A generated enum is a closed set by default, so the moment the server adds a value the client did not know about, strict generated code can throw, fail to deserialize, or silently drop the value — even though nothing the client shipped changed. The fix is to decide explicitly whether the field is truly closed or merely documented, and for open categories to generate a tolerant type that preserves unknown values rather than a hard enum. Treating every enum in the spec as closed is the root cause; the generator is only enforcing that assumption.
Enums are overused because they look precise. A status field with a handful of known values is a natural enum. But the same construct gets applied to categories, providers, and feature flags that evolve over time. The server adding a new value is a normal, backward-compatible change for an API consumer that reads strings; it is a breaking change for a consumer that generated a closed enum and validates against it. The two teams look at the same addition and disagree on whether it is a breaking change, because the decision was never made about how closed the field really is.
How the break manifests
Depending on the generator and runtime, the failure looks different, which makes it hard to diagnose. The common forms are all the same underlying issue.
- Deserialization throws. Strict parsers reject an unknown value at the boundary, so a response that the server considers valid crashes the client before the code can read it.
- Unknown value maps to a fallback. Some generators emit a sentinel such as
UNKNOWN_DEFAULT_OPEN_API, which avoids the crash but means the real value is lost and any switch statement misses it. - Silent coercion or drop. Looser code may stringify or ignore the value, hiding the mismatch until logic that depends on it behaves incorrectly.
// generated from a closed enum: safe only if the set never grows export type InvoiceStatus = "draft" | "open" | "paid" | "void"; // server later adds "uncollectible"; a strict decode now rejects the response const invoice: Invoice = decodeInvoice(responseBody); // throws on unknown status
Closed versus open: make the decision explicit
The durable fix starts in the spec, not the generator. For each field, decide whether the set is a genuine closed contract (a state machine whose values the client must exhaustively handle) or an open set of known strings (values the client recognizes but may not exhaust). Then encode that so the generator produces the right type.
| Field nature | Spec approach | Generated type | On new value |
|---|---|---|---|
| True closed state machine | enum, exhaustively handled | Union/enum with exhaustiveness check | Fail loudly; treat as breaking |
| Open category, known values | string with enum hints | Known union plus string | Preserve raw string |
| Free-form provider code | string, no enum | string | Unaffected |
| Feature flag | string or boolean | string | Unaffected |
For open sets, the goal is a type that keeps the known values for autocomplete and exhaustiveness where useful while still accepting any string. A common pattern is a union of known literals widened with string, or a branded type that preserves the raw value.
// open set: known values are convenient, unknown values are preserved export type InvoiceStatus = | "draft" | "open" | "paid" | "void" | (string & {}); const status: InvoiceStatus = responseBody.status; // never throws if (status === "paid") { ... } // known cases still narrow // unknown value reaches a default branch with the real string intact
Exhaustiveness where it is genuinely closed
For a real state machine, a closed enum is the correct choice and the strict failure is valuable: it forces every switch to handle the new state. Pair it with an exhaustiveness check so a newly added value produces a compile error at the moment the client updates the spec, rather than a runtime surprise.
function describe(status: InvoiceStatus): string { switch (status) { case "draft": return "Not yet issued"; case "open": return "Awaiting payment"; case "paid": return "Settled"; case "void": return "Voided"; default: { const _exhaustive: never = status; // compile error if a case is missing return Unknown status: ${status}; } } }
Keep the generator honest to the spec
Code generation should follow the contract rather than applying one enum policy to every field. Annotating which fields are open in the OpenAPI document lets the generator emit tolerant types for evolving categories and closed unions for true state machines, so adding a value behaves exactly as intended. Regenerating from the updated spec in CI then surfaces closed-set changes as deliberate compile errors while open fields pass through untouched — the distinction is enforced by the build instead of relying on every developer to remember it.
Frequently asked questions
Is adding an enum value a breaking change?
It depends on whether the field is closed. For a closed enum with exhaustive client handling, a new value is a breaking change that should be coordinated. For an open string field with documented known values, it is additive and tolerant clients continue to work. Decide this at design time rather than during the incident.
What if my generator has no open-enum option?
Model the field as a plain string in the spec and document the known values separately, or customize the generator template for that field. The important outcome is preserving the raw unknown value; the exact mechanism depends on the toolchain.
Why not just fail loudly on every unknown value?
Loud failure is right for a state machine the client must handle, but it turns routine additive changes into outages for categories that legitimately grow. Failing loudly for an open provider code means a harmless new value crashes the whole response. Match the strictness to the field's real contract.
How do I find the risky enums already in the spec?
Review each enum and ask whether the client must exhaustively handle every value or merely recognize the common ones. Evolving categories, provider names, and flags are candidates to convert to open types; request/response lifecycle states are the fields that justify a closed enum.
This is part of the Code Generation Guide — start there for the full set of generated-type pitfalls and how to keep them aligned with the contract.
Comments
Join the discussionNo comments yet. Be the first to comment.

