Modeling Polymorphic API Payloads with OpenAPI oneOf, anyOf, and Discriminators
Model mutually exclusive API payloads with OpenAPI oneOf, anyOf, and discriminators — with a working payment schema, validation checks, and honest tooling trade-offs.
08 Nov 2025, 05:10 UTC

Your API accepts a payment request, but the payload shape depends on the payment method: a card payment needs a token and an expiry date, a bank transfer needs an IBAN, and a wallet payment needs a wallet ID. The tempting shortcut is one big schema with every field marked optional. That works until you realize your validator can't tell a malformed card payment from a valid bank transfer, and your generated TypeScript client is a single class full of nullable fields.
OpenAPI 3.x gives you composition keywords — oneOf, anyOf, and allOf — plus the discriminator object to model this cleanly. This post explains when to use each, shows a working schema, and covers the tooling trade-offs you'll hit in practice.
oneOf vs. anyOf: the difference that matters
The two keywords sound interchangeable but validate differently:
- oneOf: the payload must match exactly one of the listed subschemas. If it matches zero or two, validation fails. Use this for mutually exclusive shapes — a payment is a card payment or a bank transfer, never both.
- anyOf: the payload must match at least one subschema, and matching several is fine. Use this for overlapping optional feature sets, like a search filter that can combine a date-range shape and a geo shape.
Choosing wrong has real consequences. If you use anyOf where the shapes are truly exclusive, a payload that accidentally satisfies two branches passes validation and your server code has to guess which interpretation applies. If you use oneOf where branches overlap structurally, legitimate payloads start failing with confusing "matched multiple schemas" errors.
A worked example: polymorphic payment requests
Here's a request body schema for the payment endpoint. It uses allOf to share common fields, oneOf for the exclusive variants, and a discriminator so tooling can pick the right type:
components:
schemas:
PaymentBase:
type: object
required: [amount, currency]
properties:
amount: { type: number }
currency: { type: string, minLength: 3, maxLength: 3 }
CardPayment:
allOf:
- $ref: '#/components/schemas/PaymentBase'
- type: object
required: [method, cardToken]
properties:
method: { type: string, enum: [card] }
cardToken: { type: string }
BankTransferPayment:
allOf:
- $ref: '#/components/schemas/PaymentBase'
- type: object
required: [method, iban]
properties:
method: { type: string, enum: [bank_transfer] }
iban: { type: string }
PaymentRequest:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankTransferPayment'
discriminator:
propertyName: method
mapping:
card: '#/components/schemas/CardPayment'
bank_transfer: '#/components/schemas/BankTransferPayment'Two details do the heavy lifting. First, each variant fixes method to a single enum value, so the schemas can't both match the same payload — that's what keeps oneOf honest. Second, the discriminator tells code generators and validators to branch on the method property instead of trial-matching every subschema.
Without a discriminator, a validator must evaluate each subschema until one passes, and code generators often fall back to a union of anonymous types. With one, OpenAPI Generator can map PaymentRequest to an interface or abstract class with CardPayment and BankTransferPayment as concrete implementations — much nicer in Java, C#, or TypeScript.
Verifying the schema actually behaves
Don't trust the YAML just because it parses. Three checks worth doing:
- Lint and validate the spec. Paste it into Swagger Editor or run a linter such as Spectral (
spectral lint openapi.yaml, run locally with Node installed). Confirm the discriminator property is markedrequiredin every subschema — a common silent mistake. - Generate a client. Run OpenAPI Generator (e.g.,
openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o ./client) and inspect the output. You want a discriminated union or base type, notany. - Test the negative case. Send a payload that matches both variants — for example, one containing both
cardTokenandibanwith amethodvalue that doesn't disambiguate the structure. A correctoneOfvalidator must reject it with a 400. If it sails through, your runtime validator isn't enforcing composition at all.
Trade-offs and tooling limits
Polymorphism is not free. Validation cost rises because parsers may evaluate multiple subschemas per request (a discriminator short-circuits this where supported, but not every validator honors it). Documentation also gets harder to scan: deeply nested allOf + oneOf chains render poorly in some documentation UIs, and human readers lose the thread quickly.
The bigger risk is generator support. Older OpenAPI 3.0-era generators — and some current ones for less mainstream languages — degrade complex oneOf structures into Object or Any, silently erasing your type safety. Before committing to a polymorphic design, generate a client for each language you actually ship and look at the types. If your generator flattens them, a flatter design (separate endpoints per payment method, or a single schema with conditional validation handled server-side) may serve consumers better.
The practical takeaway
Reach for oneOf when variants are mutually exclusive, anyOf when they can overlap, and allOf to factor out shared fields. Always pair oneOf with a discriminator backed by a required, enum-constrained property — it makes validation deterministic and code generation usable. Then verify with your actual toolchain before shipping, because the spec's promise and your generator's output are not always the same thing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.