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.

From discriminator mapping to generated union type The OpenAPI document supplies propertyName and a mapping table. The generator combines them with the variant schemas and emits a tagged union type, a deserialiser dispatch table and an unknown-variant branch. openapi.yaml propertyName: event_type mapping: 3 entries 3 variant schemas tag required + const generator resolves mapping names each variant tagged union type narrows on event_type deserialiser dispatch one lookup, no trial parsing unknown tag branch you must add this yourself

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
Trial parsing versus mapping lookup Without a discriminator the client validates the payload against branch one, then branch two, then branch three until one succeeds, and the first tolerant branch wins. With a discriminator the client reads the tag once and dispatches directly to the correct variant. No discriminator With discriminator + mapping payload try branch 1 ✗ try branch 2 ✗ try branch 3 ✓ first tolerant branch wins payload read event_type mapping lookup DeliveredEvent ✓ O(1)

Seen from the client, the mapping is what turns a union from a runtime hazard into a compile-time guarantee:

Client experience with and without an explicit mapping Without a mapping the client trial-parses branches and the first tolerant one wins. With a mapping the tag selects the branch directly and unknown values are handled explicitly. no mapping explicit mapping branches tried in declaration order first tolerant branch wins wrong variant parses silently new variant throws mid-response single property lookup exactly one branch selected unknown tag is a named case new variant degrades gracefully the mapping is four lines of YAML and removes an entire class of client bug

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.