Discriminator Mapping in Generated Clients
This page belongs to OpenAPI Schema Composition, part of the API Design Fundamentals & Architecture reference. The discriminator object is four lines of YAML that decide whether your SDK hands callers a type the compiler can narrow, or a bag of optional fields with a runtime cast in every call site. This page traces those four lines through three generators and shows the failure modes each one has.
The decision trigger
| Symptom in the generated SDK | Underlying spec cause |
|---|---|
Payment type is a merged interface with every field optional |
oneOf with no discriminator |
Deserialisation throws Unable to determine type at runtime |
mapping missing, and wire values do not match schema keys |
A switch on the tag does not narrow in TypeScript |
tag property not required, or not pinned with const |
Go client returns json.RawMessage for the union |
generator could not find an exclusive tag |
| A new server variant crashes an old client | no unknown-variant fallback around the generated parser |
| Python model accepts a payload with mismatched fields | Union without Field(discriminator=…) |
Spec snippet: the four lines that matter
# openapi.yaml (OpenAPI 3.1.0) — the discriminator object in context
components:
schemas:
ShipmentEvent:
oneOf:
- $ref: "#/components/schemas/PickedUpEvent"
- $ref: "#/components/schemas/InTransitEvent"
- $ref: "#/components/schemas/DeliveredEvent"
discriminator:
propertyName: event_type # must be a root-level string property
mapping: # wire value → component reference
picked_up: "#/components/schemas/PickedUpEvent"
in_transit: "#/components/schemas/InTransitEvent"
delivered: "#/components/schemas/DeliveredEvent"
ShipmentEventBase:
type: object
required: [event_id, event_type, occurred_at] # tag is REQUIRED
properties:
event_id: { type: string, format: uuid }
event_type: { type: string }
occurred_at: { type: string, format: date-time }
DeliveredEvent:
allOf:
- $ref: "#/components/schemas/ShipmentEventBase"
- type: object
required: [signed_by]
properties:
event_type: { const: delivered } # pinned tag value
signed_by: { type: string }
Three properties of this snippet do the work: the tag is required, the tag is pinned with const in each variant, and the mapping is written out rather than inferred. Drop any one and at least one of the three generators below degrades.
Step-by-step: from spec to a narrowing client
Step 1 — Generate the TypeScript client
# From a blank terminal
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-fetch \
-o ./sdk/ts \
--additional-properties=supportsES6=true,withoutRuntimeChecks=false
The union arrives as a discriminated type plus a dispatching deserialiser:
// sdk/ts/models/ShipmentEvent.ts (generated, abridged)
export type ShipmentEvent = PickedUpEvent | InTransitEvent | DeliveredEvent;
export function ShipmentEventFromJSON(json: any): ShipmentEvent {
switch (json["event_type"]) { // ← propertyName
case "picked_up": return PickedUpEventFromJSON(json);
case "in_transit": return InTransitEventFromJSON(json);
case "delivered": return DeliveredEventFromJSON(json);
default:
throw new Error(`No variant of ShipmentEvent exists with 'event_type=${json["event_type"]}'`);
}
}
Because each variant declares event_type: "delivered" as a literal type, consuming code narrows without casts:
function receipt(e: ShipmentEvent): string {
switch (e.event_type) {
case "delivered": return `signed by ${e.signed_by}`; // signed_by is in scope
case "in_transit": return `at ${e.current_location}`;
case "picked_up": return `from ${e.origin_facility}`;
}
}
Step 2 — Generate the Python client
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml -g python -o ./sdk/py \
--additional-properties=library=asyncio,generateSourceCodeOnly=true
Modern Python generators emit a pydantic tagged union, which validates and dispatches in one step:
# sdk/py/models/shipment_event.py (generated, abridged)
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field
class DeliveredEvent(ShipmentEventBase):
event_type: Literal["delivered"] = "delivered"
signed_by: str
ShipmentEvent = Annotated[
Union[PickedUpEvent, InTransitEvent, DeliveredEvent],
Field(discriminator="event_type"),
]
Without Field(discriminator=...), pydantic falls back to trying each member in order and reporting a combined error listing every failure — the difference between “unknown event_type ‘returned’” and forty lines of nested validation noise.
Step 3 — Generate the Go client
Go has no sum types, so generators emit a struct with one pointer per variant plus an unmarshaller that consults the tag:
// sdk/go/model_shipment_event.go (generated, abridged)
type ShipmentEvent struct {
PickedUpEvent *PickedUpEvent
InTransitEvent *InTransitEvent
DeliveredEvent *DeliveredEvent
}
func (dst *ShipmentEvent) UnmarshalJSON(data []byte) error {
var tag struct {
EventType string `json:"event_type"`
}
if err := json.Unmarshal(data, &tag); err != nil {
return err
}
switch tag.EventType {
case "delivered":
dst.DeliveredEvent = &DeliveredEvent{}
return json.Unmarshal(data, dst.DeliveredEvent)
// …other cases…
default:
return fmt.Errorf("unknown event_type %q", tag.EventType)
}
}
Without a discriminator the same generator produces a map[string]interface{} or attempts every variant in declaration order, silently binding to whichever one tolerates the payload.
Step 4 — Add the unknown-variant fallback
Every generated deserialiser above ends in a throw, a ValidationError or an error. That is the wrong default for a long-lived client: adding a variant server-side is an additive change under any reasonable breaking change detection policy, yet it hard-fails clients built before the addition. Wrap the generated function once:
// shipment-event-safe.ts — tolerate variants added after this client was built
export type SafeShipmentEvent =
| ShipmentEvent
| { event_type: "__unknown__"; raw: unknown };
export function parseShipmentEvent(json: unknown): SafeShipmentEvent {
try {
return ShipmentEventFromJSON(json);
} catch {
return { event_type: "__unknown__", raw: json };
}
}
Callers then handle __unknown__ explicitly — log it, skip the row, show a generic label — instead of the whole response failing to parse.
Standard compliance
The discriminator is defined in OpenAPI Specification 3.1 §4.8.25 and constrained more tightly than teams expect:
| Rule | Clause | Consequence if ignored |
|---|---|---|
propertyName must name a root-level property |
OAS 3.1 §4.8.25 | Generators ignore the discriminator entirely |
| The property must be a string | OAS 3.1 §4.8.25 | Integer tags fall back to trial parsing |
The property should be required |
OAS 3.1 §4.8.25 | Deserialiser must handle undefined tag |
Only valid with oneOf, anyOf, allOf |
OAS 3.1 §4.8.25 | Silently ignored elsewhere |
mapping values are refs or schema names |
OAS 3.1 §4.8.25 | Name inference fails on non-identifier values |
| No validation semantics | OAS 3.1 §4.8.25 | A stock JSON Schema validator ignores it |
Seen from the client, the mapping is what turns a union from a runtime hazard into a compile-time guarantee:
Versioning implications
A discriminator mapping is part of your wire contract, not an implementation detail. Renaming a component behind an existing wire value is safe because the mapping indirection exists; changing the wire value itself — in_transit to inTransit — breaks every deployed client that switches on the tag, and belongs in the same category as removing a field. If you must rename, add the new value as an additional mapping entry, emit both for a deprecation window announced through Sunset & Deprecation Headers, then remove the old one.
SDK / codegen downstream effect
The concrete diff between an undiscriminated and a discriminated union, in the generated TypeScript:
- // No discriminator: one merged interface, everything optional
- export interface ShipmentEvent {
- event_id?: string; event_type?: string; occurred_at?: string;
- signed_by?: string; current_location?: string; origin_facility?: string;
- }
+ // With discriminator: a union the compiler can narrow
+ export type ShipmentEvent = PickedUpEvent | InTransitEvent | DeliveredEvent;
+ export interface DeliveredEvent extends ShipmentEventBase {
+ event_type: "delivered";
+ signed_by: string;
+ }
The first form compiles everywhere and fails at runtime; the second refuses to compile the moment a caller reads signed_by off an in-transit event. That difference is the entire argument for writing four extra lines of YAML.
Common mistakes
| Mistake | Correct approach |
|---|---|
Relying on schema-name inference instead of writing mapping |
Map every wire value to an explicit $ref |
Leaving the tag out of required |
Add it to required in the base schema |
Not pinning the tag with const per variant |
Pin it so branches are provably exclusive and types narrow |
Using a nested property such as data.type as the tag |
Lift the tag to the root of each variant schema |
| Letting the generated deserialiser throw on unknown tags | Wrap it with a typed unknown-variant fallback |
| Changing a wire tag value to fix casing | Add the new value alongside the old, deprecate, then remove |
FAQ
Do I need a mapping if my schema names already match the wire values?
Write the mapping anyway. Without it, a generator infers the component name from the property value, which works only while every wire value is a valid identifier that matches a schema key exactly. The moment a value contains an underscore, a hyphen, or different casing — bank_transfer against a BankTransferPayment component — inference fails, and the failure mode is generator-specific: some throw, some silently emit an untyped union. An explicit mapping also decouples the two, so you can rename a component without touching the wire contract.
What happens in a generated client when the server sends an unknown discriminator value?
Almost every generator raises an error from inside the deserialiser, so an additive server change surfaces as an exception in the middle of parsing a response — often inside a list, taking the whole page down rather than one row. Wrap the generated parser so an unmapped tag yields a typed unknown-variant value carrying the raw JSON. Callers then decide whether to skip, log, or render a generic representation, and a client built today survives variants added next year.
Can the discriminator property be nested inside an object?
No. OpenAPI requires propertyName to name a property at the root of the schema being discriminated, and that property must be a string. A tag at data.type is invisible to the discriminator: tooling either ignores the whole object or fails to resolve a branch. If the nesting is fixed by an existing contract, you have two options — lift a copy of the tag to the root of each variant, or drop the discriminator and accept that clients will trial-validate branches in order.
Related
- OpenAPI Schema Composition — up-link: the section covering
allOf,oneOf,anyOfand component reuse - API Design Fundamentals & Architecture — up-link: the parent reference for contract-first API design
- allOf vs oneOf for Polymorphic Payloads — choosing the applicator the discriminator sits on
- Naming Operation IDs for Readable SDK Methods — the other spec field that shapes generated client ergonomics
- Semver Ranges for Generated SDK Clients — releasing the SDK once a new variant lands