Skip to main content

Command Palette

Search for a command to run...

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

Updated
•6 min read•View as Markdown

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 enum generates a union ("visa" | "mastercard") or an enum class. Exhaustive switches are safe only while the set is truly closed.
  • const generates 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 wallet but 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

  1. Use const for fixed structural tags, enum for genuinely closed sets, plain string for open sets.
  2. Before publishing an enum, mark who can extend it and whether unknown response values are expected.
  3. Open sets use x-extensible-enum (documented known values) or 3.1 anyOf with an open subschema.
  4. Treat adding a response enum value as potentially breaking; add the client unknown-handling policy first.
  5. Keep wire tokens stable; put human labels and edge cases (zero-decimal currencies, terminal states) in descriptions.
  6. Model null with type: [string, "null"], not by adding the word null into enum.
  7. 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.

More from this blog

P

Powerduck Blogs

117 posts

Essays on local-first API tooling, OpenAPI contracts, MCP, and agentic coding failures. We build Powerduck, a local-first OpenAPI studio where the spec stays the source of truth. Topics: AI code review trust, testing AI-generated code, context engineering, and what actually breaks when AI writes your code.