OpenAPI 3.1 Discriminator: Making Polymorphic Payloads Work in Generated Code
OpenAPI 3.1’s discriminator object turns polymorphic webhook payloads into real sum types for Kotlin, TypeScript, and Go — if you map component names correctly and avoid common generator pitfalls.
21 May 2026, 08:21 UTC

The problem: one endpoint, many shapes
Webhooks, event streams, and command APIs often accept a single JSON body that can represent several distinct types — UserCreated, OrderShipped, PaymentFailed. Without a contract, consumers write fragile if (payload.type === 'user_created') checks and cast to any. OpenAPI 3.1’s discriminator object lets the spec declare the routing field and the mapping from that field’s value to a component schema, so generators can emit real sum types (TypeScript discriminated unions, Kotlin sealed interfaces, Java visitor hierarchies) instead of opaque Object.
How the discriminator maps to JSON Schema 2020‑12
OpenAPI 3.1 aligns its schema vocabulary with JSON Schema 2020‑12. A polymorphic payload is expressed with oneOf (or anyOf) referencing named component schemas. The discriminator object then adds two pieces:
- propertyName – the JSON field that carries the type tag (must be a required string in every composed schema).
- mapping – an object whose keys are the exact component schema names (the keys under
components/schemas) and whose values are the wire values that will appear inpropertyName.
If the mapping key does not match a component name, generators such as openapi-generator, orval, or swagger-codegen fail at code‑gen time even though the spec passes lint.
Tooling reality check
| Generator | Output for a discriminated oneOf | Unknown‑value behaviour |
|---|---|---|
| openapi-generator (Kotlin) | sealed interface + data classes | throws IllegalArgumentException at deserialization |
| orval (TypeScript) | discriminated union type Event = UserCreated | OrderShipped | falls back to base type EventBase (configurable) |
| swagger-codegen (Go) | interface + structs + UnmarshalJSON switch | returns nil and logs warning |
All three require the discriminator.mapping keys to be component names; inline schemas (no $ref) degrade to any/Object because the generator cannot assign a stable type name.
Worked example: a webhook event payload
openapi: 3.1.0
info:
title: Acme Webhooks
version: 1.0.0
components:
schemas:
EventBase:
type: object
required: [eventType]
properties:
eventType:
type: string
UserCreatedEvent:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
required: [userId, email]
properties:
userId:
type: string
format: uuid
email:
type: string
format: email
OrderShippedEvent:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
required: [orderId, trackingNumber]
properties:
orderId:
type: string
format: uuid
trackingNumber:
type: string
paths:
/webhooks:
post:
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/UserCreatedEvent'
- $ref: '#/components/schemas/OrderShippedEvent'
discriminator:
propertyName: eventType
mapping:
UserCreatedEvent: user_created
OrderShippedEvent: order_shipped
responses:
'202':
description: Accepted
Key points in the snippet:
- Each concrete schema includes
eventTypeas a required string (inherited fromEventBase). - The
mappingkeys (UserCreatedEvent,OrderShippedEvent) are the component names, not the wire values. - The wire values (
user_created,order_shipped) are what the producer actually sends.
Generating a Kotlin client
Run the generator from a directory that contains spec.yaml (the file above). You need write permission to the output folder.
# Validate first – catches missing mapping keys early
openapi-generator validate -i spec.yaml
# Generate Kotlin client with Spring WebFlux
openapi-generator generate \
-i spec.yaml \
-g kotlin-spring \
-o ./generated/kotlin-client \
--additional-properties=interfaceOnly=true,serializationLibrary=jackson
Expected check: the generated Event.kt contains a sealed interface Event with two data‑class implementations. If you see Any or a generic Object, the discriminator was not honoured — usually because the oneOf entries were inline instead of $ref.
Trade‑offs and when to skip the discriminator
- Breaking changes: Renaming a component schema or changing
propertyNameforces every consumer to regenerate. Version the schema name (UserCreatedEventV2) instead of mutating the discriminator. - Validator blind spot: JSON Schema 2020‑12 treats
discriminatoras an annotation, not a validation keyword. AJV and similar validators ignore it unless you add a custom keyword. Lint tools (Spectral, Vacuum) may not verify that mapping keys exist as components — CI can pass while code‑gen fails. - AllOf anti‑pattern: Combining
allOfwith a discriminator produces specs that no mainstream generator can turn into a clean sum type. Keep the polymorphic layer at theoneOflevel. - Simple cases: If you control both producer and consumer and the type set is stable, a manual
typefield plus a small factory function can be lighter than generator overhead.
Verification checklist you can run today
- Add the
discriminator-mappingrule to your Redocly config and runnpx @redocly/openapi-cli lint spec.yaml. It will flag missing mapping keys. - Generate clients for at least two target languages (e.g., Kotlin and TypeScript) and inspect the emitted type hierarchy.
- Write a tiny integration test: serialize a
UserCreatedEventwitheventType: "user_created", POST it to a test server using the generated client, and assert the deserialized object is an instance of the concrete class. - Document the unknown‑value policy in the spec’s
descriptionor anx-discriminator-unknownextension so downstream teams know whether to expect an exception, a fallback, or a logged warning.
Bottom line
The OpenAPI 3.1 discriminator gives you a single source of truth for polymorphic payloads, but it only pays off when you keep component schemas named, referenced by $ref, and versioned deliberately. Validate early, generate for every consumer language, and treat the discriminator as a contract — not a convenience — to avoid the “works in spec, breaks in code” surprise.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.