OpenAPI enums vs const: closed lists, open-ended strings, and how to add a value without breaking clients
An enum is a promise the server only accepts those values; a const is one fixed value; an open string accepts anything. Teams blur the three and ship a breaking change every time they add an option. Here is the decision table, extensible enums, and what codegen and AI callers do
Adding a value to an enum should be the safest change in a release. It is routinely the most dangerous. The spec said enum: [card, transfer], the mobile client shipped a switch with no default, and the moment the backend starts returning wallet that client crashes on every checkout. The bug is not the new value. It is that the spec never said whether the list was closed.
OpenAPI gives you three distinct ways to constrain a string, and they make opposite promises.
The three shapes
| Keyword | What it promises | Use when |
|---|---|---|
const |
Exactly this one value, always | Discriminator tags, fixed type fields, webhook names |
enum |
A closed set; nothing else is valid | Status machines, currencies you actually settle, fixed categories |
Plain string (optionally with pattern) |
An open set; unknown values are expected | User-supplied labels, tags, provider-defined categories |
const is not a one-element enum. A one-element enum still means "a list that could grow," and some generators treat it as an array-backed union. const means "this value is structural and will never change," which lets generators emit a literal type.
const is for structural tags
A discriminated union needs a tag that is fixed per variant. That is a const, not an enum:
PaymentMethod:
type: object
required: [kind]
properties:
kind:
type: string
const: card
brand:
type: string
enum: [visa, mastercard, amex]
discriminator:
propertyName: kind
mapping:
card: '#/components/schemas/CardPayment'
A TypeScript generator emits kind: "card" as a literal, so the discriminated union narrows for free. Model kind as an open enum and the union cannot be narrowed at all.
enum is a contract, not documentation
Listing values in enum means the server rejects anything else on input and never returns anything else on output. That is a strong, two-directional guarantee, and it is why adding a value is asymmetric:
| Change | Request direction | Response direction |
|---|---|---|
| Add an enum value | Safe: old clients simply never send it | Potentially breaking: old clients must decode an unknown value |
| Remove a value | Breaking: clients may still send it | Safe for decoding, but usually means a feature disappeared |
| Rename a value | Breaking | Breaking |
The response direction is the one people miss. Your database gains a new order status, the API returns it, and every consumer with an exhaustive decode fails closed. Before you publish an enum, decide who is allowed to extend the set.
Model an open set honestly
If providers, plugins, or future versions can introduce values you do not know today, do not put enum down. You have two honest options.
An open string with documented known values, using the widely supported x-extensible-enum extension (the same shape Zalvo popularized and Redoc renders):
status:
type: string
description: >-
Lifecycle state. New values may be added; clients MUST treat an unknown
value as a non-terminal state and keep polling rather than failing.
x-extensible-enum:
- value: pending
description: Accepted, work not started.
- value: running
description: At least one step has started.
- value: succeeded
description: Terminal success.
- value: failed
description: Terminal failure; see error.
examples: [pending]
Or, in OpenAPI 3.1, a union of the closed list and an open string:
provider:
type: string
anyOf:
- enum: [stripe, paddle, adyen]
- {}
The anyOf with an empty schema says "one of the known providers, or any other string." Strict generators that do not support the extension still produce a string, which is the correct open type. Do not use anyOf: [enum, string] with a bare string keyword declared twice in a way that contradicts itself; the empty subschema is the idiomatic form.
Describe values without changing the type
Use description on the schema and, where your toolchain renders it, x-enumDescriptions to explain each token. Keep the wire tokens stable, machine-friendly, and lowercase; put the human label in the description, not in the enum:
currency:
type: string
enum: [USD, EUR, JPY]
description: ISO 4217 three-letter code. Settlement currency only.
x-enumDescriptions:
USD: United States dollar
EUR: Euro
JPY: Japanese yen (zero-decimal; amounts are whole yen)
The zero-decimal note for JPY is exactly the kind of fact that belongs next to the enum, because it changes how a client formats amounts.
Nullable and unknown enums
In OpenAPI 3.1, an enum that also allows null lists null in the type, not in the values:
canceled_reason:
type: [string, "null"]
enum: [customer_request, fraud, null]
For an extensible field that may also be absent, prefer omitting the property over sending null, and say so in the description. Distinguish "not applicable" (omitted) from "explicitly cleared" (null) the same way you would for any other field.
What codegen and AI callers do
- A closed
enumgenerates a union ("visa" | "mastercard") or an enum class. Exhaustive switches are safe only while the set is truly closed. constgenerates a literal type, which is what makes a discriminated union compile.- An open string generates
string, which is correct: the client cannot assume the value set. - A spec-driven mock and an AI agent generating test data will only ever invent values that appear in a closed enum. If real traffic includes
walletbut the spec omits it, the mock can never reproduce a production bug. Honest open-set modeling is what lets an AI caller include an unknown-value test instead of pretending the world is closed.
Clients should always decode enums defensively: keep an unknown branch on response enums, even when the generator produces a union. The generator guarantees the spec; it cannot guarantee the server never gets ahead of the published spec.
Checklist
- Use
constfor fixed structural tags,enumfor genuinely closed sets, plainstringfor open sets. - Before publishing an enum, mark who can extend it and whether unknown response values are expected.
- Open sets use
x-extensible-enum(documented known values) or 3.1anyOfwith an open subschema. - Treat adding a response enum value as potentially breaking; add the client unknown-handling policy first.
- Keep wire tokens stable; put human labels and edge cases (zero-decimal currencies, terminal states) in descriptions.
- Model null with
type: [string, "null"], not by adding the word null intoenum. - Generate the client once and confirm it has an unknown-value path for every response enum.
Get these seven right and adding wallet becomes a non-event instead of a hotfix, because the contract already told every client that the list might grow.
You can model closed and extensible enums, generate a client that keeps an unknown branch, and exercise both known and unknown values against a mock in one local-first workspace, right in your browser. For the discriminated-union case these tags belong to, see modeling polymorphism with oneOf and discriminator.

