How-to › Describe your API

How to model a polymorphic response with a discriminator#

Describe a response that comes in several shapes so a generator emits a tagged union rather than a bag of optional fields, using oneOf with a discriminator.

Audience
API producer
Level
intermediate
Topic
Write an OpenAPI description
Languages
TypeScript and JavaScript
Verified

Your events endpoint returns three kinds of event, and the description says one object with eleven optional properties. The generated type has every field marked optional, so consumers write null checks on kwh for a fault event that can never carry one. Nobody can tell from the types which combinations are real.

What you get

You will end up with a description that names each variant, selects between them on one property, and shares the common fields once. Generators emit a tagged union, validators stop guessing, and a reader can see which fields travel together. This is for you if one endpoint returns more than one shape.

Short answer

Put the variants under oneOf, add a discriminator naming the property that selects between them, and give every variant an explicit mapping entry. Share the common fields through an allOf base, and fix the discriminator property to a const inside each variant. A reader, a validator, and a generator then all pick the branch the same way.

You will need

An OpenAPI 3.1 description, and Node 22 or later. The keywords come from JSON Schema, and the discriminator object is OpenAPI’s own addition on top of them.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
One flat object with optional fieldsTwo variants that differ by one field, where a union is more ceremony than valueEvery field optional, so the types permit combinations that never occurA third variant arrives, or the combinations start to matter
A separate endpoint per variantThe variants are genuinely different resources with different lifecyclesClients call several endpoints and merge, and ordering across them is theirs to solveThe variants arrive interleaved in one stream
oneOf with a discriminatorAny response with three or more shapes that share a tag propertyA mapping to keep current, and tooling that varies in how well it reads oneNo property identifies the variant
oneOf with no discriminatorVariants with no shared tag, where structure alone separates themValidators try every branch, and the error names all of them rather than the right oneA tag property exists or can be added

The choice is about who does the selecting. Without a discriminator, a validator tries each branch and reports failures from all of them, which produces error messages nobody can act on. With one, the property names the schema, validation happens once, and a failure is about the branch the payload claimed to be.

Generators go further than validators here. A discriminator is what turns oneOf into a tagged union in a typed language, which is the difference between a switch the compiler can check and a chain of field presence tests. That is worth the mapping even when your validator does not need it.

Compose the base, then fix the tag

Two keywords carry the design: allOf for the shared fields, const for the tag.2

"MeterFault": {
  "allOf": [
    { "$ref": "#/components/schemas/EventBase" },
    { "type": "object", "required": ["code"], "properties": { "kind": { "const": "fault" }, "code": { "type": "string" } } }
  ]
}

The const is what stops a payload claiming one variant and carrying another. Without it the variant schema accepts any string in kind, so a fault event with kind set to reading validates against the fault branch, which is the shape a bug produces.

Write the mapping out in full rather than relying on the implicit form. An implicit mapping matches a schema by name, which means renaming a component silently changes what the wire values mean. The mapping is part of your contract, and a contract should not move when somebody tidies a file.

Select once, then validate

Resolution is a lookup, not a search.

const ref = mapping[key]
if (!ref) {
  return { ok: false, reason: `${propertyName} "${key}" is not in the mapping (${Object.keys(mapping).join(', ')})` }
}
return { ok: true, name: ref.split('/').pop(), schema: flatten(doc, ref) }

An unmapped tag is an error worth reporting well. It is what a consumer sees the day you add a fourth event kind. A message listing the kinds it does know turns a mystery into a version problem, which is something they can act on.

Decide what a client should do with an unknown variant before you ship the second one. Ignoring it is usually right for a stream, and failing is usually right for a payment. Write the rule in the description, beside the mapping, where a client author will read it.

Check it worked

Resolve six payloads: three good, and three wrong in three different ways.

node demo.mjs
reading          MeterReading valid
fault            MeterFault   valid
swap             MeterSwap    valid
unknown          unresolved  kind "tamper" is not in the mapping (reading, fault, swap)
missingField     MeterFault   code: missing
noDiscriminator  unresolved  no kind property

The last three lines are three different failures, and keeping them apart is the benefit. An unmapped tag is a version problem. A missing field is a producer bug. A payload with no tag at all is a different endpoint, or a truncated body. A oneOf without a discriminator collapses all three into one message about every branch failing, and a support ticket that starts with that message takes an hour longer to answer.

node --test dispatch.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 112.885242

When it goes wrong

The generated client has one type with every field optional. The description used anyOf, or the discriminator is missing. Check what the generator produced rather than what the description says.

Validation fails with an error naming all three branches. The discriminator is present and the validator ignores it. Some validators support it and some do not, so test yours before relying on the error text. The specification permits both, which makes this a property of your tooling rather than of your document.3

A rename broke every consumer. The mapping was implicit and a component was renamed. Write the mapping explicitly, and treat its values as wire format.

The discriminator property is absent from a variant’s properties. A generator then emits a union with no tag to switch on. Declare the property in every branch, with its const. Leaving it to the base schema alone is valid and it produces worse code.

When not to do this

Do not add a discriminator to a two-variant response that differs by one field. The union is real and so is the ceremony, and an optional field with a comment may serve a small API better. Revisit it at the third variant, which is where the flat object starts to lie.

Do not use a discriminator value that is also a display string. Wire values outlive labels, and a mapping keyed on something a designer may rewrite is a breaking change waiting for a redesign.

Do not model unrelated resources as variants to save an endpoint. A union of things that share no meaning gives consumers a switch over your internal organization, and it becomes a compatibility problem the first time you reorganize.

Do not nest unions inside unions without checking your tooling. Several generators handle one level and flatten the second, and the resulting types look plausible while permitting nonsense.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The specification is careful about what the object is. It is legal only beside oneOf, anyOf or allOf, inline schemas are not considered when it is used, and it exists to aid serialization in both directions, and validation. In the worked example it becomes a hint, in quotation marks, that MAY shortcut validation. An aid that tooling may take or leave is the whole of the difference between a validator that honors it and one that does not. ↩︎ Back to text

  2. The JSON Schema reference marks const as new in draft 6 and defines it as restricting a value to a single value, which is the entire keyword. Its example restricts a country to the United States of America, for export reasons. A discriminator tag is the same idea with a quieter value. ↩︎ Back to text

  3. Ajv is a case in point. Its documentation describes its support for the keyword as limited, applies it to oneOf alone, and requires discriminator: true in the constructor options, because it is not enabled by default. A validator that understands the discriminator and ships with it switched off is honoring the specification exactly. The hint is optional, and so is taking it. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.