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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| One flat object with optional fields | 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 | 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 | 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 | 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
"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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The specification is careful about what the object is. It is legal only beside
oneOf,anyOforallOf, 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 -
The JSON Schema reference marks
constas 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 -
Ajv is a case in point. Its documentation describes its support for the keyword as limited, applies it to
oneOfalone, and requiresdiscriminator: truein 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