# 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.

Source: https://voxgig.com/howto/polymorphic-response-with-a-discriminator

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://json-schema.org/understanding-json-schema/reference/combining), and the
`discriminator` object is [OpenAPI's own
addition](https://spec.openapis.org/oas/v3.1.0.html#discriminator-object) on top of them.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [One flat object with optional fields](https://json-schema.org/understanding-json-schema/reference/object) | Two variants that differ by one field, where a union is more ceremony than value | Every field optional, so the types permit combinations that never occur | A third variant arrives, or the combinations start to matter |
| [A separate endpoint per variant](https://spec.openapis.org/oas/v3.1.0.html#paths-object) | The variants are genuinely different resources with different lifecycles | Clients call several endpoints and merge, and ordering across them is theirs to solve | The variants arrive interleaved in one stream |
| [oneOf with a discriminator](https://spec.openapis.org/oas/v3.1.0.html#discriminator-object) | Any response with three or more shapes that share a tag property | A mapping to keep current, and tooling that varies in how well it reads one | No property identifies the variant |
| [oneOf with no discriminator](https://json-schema.org/understanding-json-schema/reference/combining#oneOf) | Variants with no shared tag, where structure alone separates them | Validators try every branch, and the error names all of them rather than the right one | A 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]

```json title="openapi.json"
"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.

```js title="dispatch.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test dispatch.test.mjs
```

```text output
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.

## Related how-tos

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

- [Write a validator whose schema looks like the data](/howto/write-a-schema-that-looks-like-the-data)

## Last verified

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

[^1]: [The specification](https://spec.openapis.org/oas/v3.1.0.html#discriminator-object) 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.

[^2]: The [JSON Schema reference](https://json-schema.org/understanding-json-schema/reference/const)
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.

[^3]: Ajv is a case in point. [Its documentation](https://ajv.js.org/json-schema.html#discriminator)
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.