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 natureSpec approachGenerated typeOn new value
True closed state machineenum, exhaustively handledUnion/enum with exhaustiveness checkFail loudly; treat as breaking
Open category, known valuesstring with enum hintsKnown union plus stringPreserve raw string
Free-form provider codestring, no enumstringUnaffected
Feature flagstring or booleanstringUnaffected

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.