Modeling Polymorphic Types with oneOf in OpenAPI
A paymentMethod field that can hold a card, a bank transfer, or a digital wallet is one of the most common polymorphic shapes in production APIs, and one of the most frequently modeled incorrectly. The symptom is validation that accepts nonsense payloads, error messages that blame the wrong branch, and a generated TypeScript type that collapses to CardPayment | BankPayment | WalletPayment with no discriminant — useless in a switch statement and invisible to narrowing. This guide is part of Handling Complex Nested Objects in API Schemas and provides the exact OpenAPI 3.1 structure and the matching Zod schema to fix all three problems in one pass.
Symptom: Ambiguous Validation and Useless Union Types
The failure presents in two places simultaneously.
At validation time, a CardPayment payload that is missing the required cardNumber field still passes because it satisfies the WalletPayment subschema, which does not require cardNumber. The validator reports success; the handler receives a partially-filled object and either crashes or silently drops data.
When validation does fail, the error is useless:
data must match exactly one schema in oneOf
branch 0 (CardPayment): must have required property 'cardNumber'
branch 1 (BankPayment): must have required property 'routingNumber'
branch 2 (WalletPayment): must have required property 'walletId'
Every branch failed. Nothing tells you which branch the client intended, and nothing points at the actual wrong field.
At codegen time, openapi-typescript 7 generates:
// BEFORE: discriminator missing
export type PaymentMethod =
| components["schemas"]["CardPayment"]
| components["schemas"]["BankPayment"]
| components["schemas"]["WalletPayment"];
TypeScript cannot narrow this type in a switch block because no shared literal field distinguishes the branches. Every access to a branch-specific field requires a cast or a manual type guard.
Root Cause: oneOf Without a Discriminator
OpenAPI’s oneOf keyword guarantees that a payload matches exactly one subschema, but it says nothing about how a validator should determine which branch to check first. Without a discriminator, conformant validators try every branch sequentially, collect the failures, and report them all. The result is O(n) evaluation — every branch is always tested — and error messages that describe every branch’s failure simultaneously.
The discriminator solves both problems. It declares a specific tag property (e.g. type, kind, channel, method) whose value identifies the intended branch, and a mapping that links each tag value to the exact subschema. A validator can read the tag, skip to the correct subschema, and validate only that one. The error now points at the real offending field.
The secondary cause is missing additionalProperties: false on each branch. Without it, an object that has cardNumber but no walletId can still match WalletPayment if WalletPayment declares no required fields — making oneOf semantically equivalent to anyOf.
Step-by-Step Fix
1. Define Each Variant as a Named Component Schema
Move each concrete type into components.schemas. Each schema must:
- Declare the shared tag property as
required. - Pin the tag to exactly one constant value using
enum: [value]. - Set
additionalProperties: falseso cross-branch ambiguity is impossible.
# openapi.yaml (OpenAPI 3.1)
components:
schemas:
CardPayment:
type: object
required: [method, cardNumber, expiryMonth, expiryYear, cvv]
additionalProperties: false # prevents cross-branch leakage
properties:
method:
type: string
enum: [card] # constant tag — pins this branch
cardNumber:
type: string
pattern: '^\d{13,19}$'
expiryMonth:
type: integer
minimum: 1
maximum: 12
expiryYear:
type: integer
minimum: 2024
cvv:
type: string
pattern: '^\d{3,4}$'
BankPayment:
type: object
required: [method, routingNumber, accountNumber, accountHolderName]
additionalProperties: false
properties:
method:
type: string
enum: [bank] # different constant tag
routingNumber:
type: string
pattern: '^\d{9}$'
accountNumber:
type: string
minLength: 4
maxLength: 17
accountHolderName:
type: string
WalletPayment:
type: object
required: [method, walletProvider, walletId]
additionalProperties: false
properties:
method:
type: string
enum: [wallet]
walletProvider:
type: string
enum: [apple_pay, google_pay, paypal]
walletId:
type: string
format: uuid
Why enum: [card] and not just type: string? A single-value enum is a JSON Schema constant. A validator can short-circuit the entire branch by checking this one field before evaluating anything else. It is also what makes codegen emit method: "card" as a literal type rather than method: string.
2. Compose the Union with a Discriminator and Mapping
The parent schema references all branches via oneOf and adds a discriminator block:
components:
schemas:
PaymentMethod:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
- $ref: '#/components/schemas/WalletPayment'
discriminator:
propertyName: method # the tag field present in every branch
mapping: # explicit map: tag value -> subschema $ref
card: '#/components/schemas/CardPayment'
bank: '#/components/schemas/BankPayment'
wallet: '#/components/schemas/WalletPayment'
The mapping is technically optional in the OpenAPI 3.1 spec — if omitted, the validator is supposed to derive it from the component name. Do not omit it. Component names and tag values diverge across teams and versions. An explicit mapping is the only guarantee that card always resolves to CardPayment regardless of how the schema is bundled or renamed.
The PaymentMethod schema is then used as the request body wherever a payment is accepted:
paths:
/payments:
post:
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentMethod'
responses:
'201':
description: Payment accepted
3. Enable Discriminator Resolution in Ajv
Ajv 8.17 supports the discriminator keyword but requires it to be enabled explicitly. Without the flag, Ajv ignores the discriminator block and falls back to exhaustive branch evaluation.
import Ajv from "ajv"; // ajv 8.17
import addFormats from "ajv-formats"; // ajv-formats 3.0
const ajv = new Ajv({
strict: true,
allErrors: false, // fail fast — one precise error per request
discriminator: true, // enables discriminator-aware oneOf resolution
});
addFormats(ajv);
// Load the bundled OpenAPI components schemas — extract or bundle first
import schemas from "./schemas.json";
// Compile once at startup; the compiled function is reused per request
const validatePaymentMethod = ajv.compile(schemas.PaymentMethod);
export function validatePayment(payload: unknown) {
const valid = validatePaymentMethod(payload);
if (!valid) {
return { ok: false as const, errors: validatePaymentMethod.errors! };
}
return { ok: true as const };
}
With discriminator: true and allErrors: false, a CardPayment payload with an invalid CVV now produces:
data/cvv must match pattern "^\d{3,4}$"
One error. Correct path. No noise from the other branches.
4. Mirror as a Zod Discriminated Union
Runtime validation at the application boundary should use z.discriminatedUnion, not z.union. The difference: z.union tries each branch in order (same problem as oneOf without a discriminator); z.discriminatedUnion reads the tag field and jumps directly to the matching branch. For three branches the difference is minor; for fifteen it is significant.
import { z } from "zod"; // zod 3.23
const CardPayment = z.object({
method: z.literal("card"), // z.literal is the Zod equivalent of enum: [card]
cardNumber: z.string().regex(/^\d{13,19}$/),
expiryMonth: z.number().int().min(1).max(12),
expiryYear: z.number().int().min(2024),
cvv: z.string().regex(/^\d{3,4}$/),
}).strict(); // .strict() == additionalProperties: false
const BankPayment = z.object({
method: z.literal("bank"),
routingNumber: z.string().regex(/^\d{9}$/),
accountNumber: z.string().min(4).max(17),
accountHolderName: z.string(),
}).strict();
const WalletPayment = z.object({
method: z.literal("wallet"),
walletProvider: z.enum(["apple_pay", "google_pay", "paypal"]),
walletId: z.string().uuid(),
}).strict();
// z.discriminatedUnion(tagField, [branch, branch, ...])
export const PaymentMethod = z.discriminatedUnion("method", [
CardPayment,
BankPayment,
WalletPayment,
]);
export type PaymentMethod = z.infer<typeof PaymentMethod>;
// Inferred type:
// | { method: "card"; cardNumber: string; expiryMonth: number; expiryYear: number; cvv: string }
// | { method: "bank"; routingNumber: string; accountNumber: string; accountHolderName: string }
// | { method: "wallet"; walletProvider: "apple_pay" | "google_pay" | "paypal"; walletId: string }
The inferred type is a proper discriminated union. TypeScript narrows it correctly in a switch on method without a cast.
For the Zod-specific failure mode where z.discriminatedUnion reports an unexpected error even when data appears valid, see fixing Zod discriminated union mismatches.
Before / After Comparison
Before — oneOf without discriminator, no additionalProperties, no constant tag:
# BEFORE (broken)
PaymentMethod:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
- $ref: '#/components/schemas/WalletPayment'
# CardPayment has no additionalProperties constraint, method is type: string
Validation result for { "method": "card", "walletId": "abc" }:
PASSES — WalletPayment has no required fields in this under-specified version.
Generated type:
type PaymentMethod = CardPayment | BankPayment | WalletPayment;
// No discriminant: narrowing requires manual type guards.
After — oneOf with discriminator, mapping, additionalProperties: false, constant enum tag:
# AFTER (correct)
PaymentMethod:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
- $ref: '#/components/schemas/WalletPayment'
discriminator:
propertyName: method
mapping:
card: '#/components/schemas/CardPayment'
bank: '#/components/schemas/BankPayment'
wallet: '#/components/schemas/WalletPayment'
# Each branch: additionalProperties: false, method: { enum: [card|bank|wallet] }
Validation result for { "method": "card", "walletId": "abc" }:
FAILS — data/walletId must NOT have additional properties (CardPayment branch)
Generated type (openapi-typescript 7):
export type PaymentMethod =
| { method: "card"; cardNumber: string; expiryMonth: number; expiryYear: number; cvv: string }
| { method: "bank"; routingNumber: string; accountNumber: string; accountHolderName: string }
| { method: "wallet"; walletProvider: "apple_pay" | "google_pay" | "paypal"; walletId: string };
// TypeScript narrows correctly on `switch (payment.method)`.
Verification
Run the following in sequence. All three must pass before merging.
# 1. Lint the spec — Spectral catches missing discriminator and unbounded oneOf
npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml
# Expected: No results with a severity of "error" found!
# 2. Valid card payload passes
node -e "
const { validatePayment } = require('./dist/validate');
const result = validatePayment({
method: 'card', cardNumber: '4111111111111111',
expiryMonth: 12, expiryYear: 2026, cvv: '123'
});
console.log(result);
"
# { ok: true }
# 3. Cross-branch payload is rejected with a precise error
node -e "
const { validatePayment } = require('./dist/validate');
const result = validatePayment({ method: 'card', walletId: 'not-a-card' });
console.log(JSON.stringify(result.errors, null, 2));
"
# [{
# "instancePath": "/walletId",
# "keyword": "additionalProperties",
# "message": "must NOT have additional properties"
# }]
# 4. Unknown tag value is rejected immediately (no branch matched)
node -e "
const { validatePayment } = require('./dist/validate');
console.log(validatePayment({ method: 'crypto', address: '0xabc' }));
"
# { ok: false, errors: [{ message: 'must be equal to one of the allowed values' }] }
Add the Spectral rules to .spectral.yaml to gate the spec in CI:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
oneof-must-have-discriminator:
description: "Every oneOf with more than one branch must declare a discriminator"
given: "$..[?(@.oneOf && @.oneOf.length > 1)]"
severity: error
then:
field: discriminator
function: defined
discriminator-must-have-mapping:
description: "discriminator must declare an explicit mapping"
given: "$..discriminator"
severity: error
then:
field: mapping
function: defined
The CI gate:
# .github/workflows/schema-gate.yml
jobs:
contract-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Lint OpenAPI spec
run: npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml
- name: Validate payment fixtures
run: npm run test:payment-fixtures
A red Spectral run that catches a missing discriminator before merge is worth more than any post-deployment fix.
Edge Cases and Caveats
anyOf vs oneOf for genuinely overlapping shapes. anyOf is the correct keyword when a payload is allowed to match more than one branch simultaneously — for example, a PremiumCard that satisfies both CardPayment and LoyaltyPayment. In practice this is rare for payment-style fields. If you find yourself reaching for anyOf on a tagged union, the shapes probably need to be redesigned. Use oneOf for exclusive variants; use anyOf only when multi-branch matching is intentional and documented. The OpenAPI Specification Deep Dive covers the full JSON Schema keyword semantics.
Nested discriminators. A WalletPayment may itself need sub-variants: ApplePay has a merchantId, GooglePay has a merchantOrigin, PayPal has a payerId. Model this as a second discriminated union nested inside WalletPayment:
WalletPayment:
type: object
required: [method, walletDetails]
additionalProperties: false
properties:
method:
type: string
enum: [wallet]
walletDetails:
oneOf:
- $ref: '#/components/schemas/ApplePayDetails'
- $ref: '#/components/schemas/GooglePayDetails'
- $ref: '#/components/schemas/PayPalDetails'
discriminator:
propertyName: provider
mapping:
apple_pay: '#/components/schemas/ApplePayDetails'
google_pay: '#/components/schemas/GooglePayDetails'
paypal: '#/components/schemas/PayPalDetails'
Each nested branch follows the same pattern: constant tag, additionalProperties: false, explicit mapping. In Zod the equivalent is a second z.discriminatedUnion assigned to the walletDetails field. Nesting depth is not a practical limit here — discriminator-resolved branches are O(1) at each level regardless of depth.
Adding a variant without breaking consumers. Append the new subschema to oneOf, add its tag value to mapping, and use an additive enum value in the tag property (enum: [crypto]). Existing generated clients that do not know crypto will reject the response if they validate strictly — which is correct behavior. Update consumer contract tests in lock-step. The process mirrors the breaking-change analysis described in validating deeply nested JSON payloads in Node.js for backward-compatible schema evolution.
Frequently Asked Questions
Why does oneOf without a discriminator produce slow validation?
Without a discriminator the validator trial-evaluates every branch in sequence and collects errors from each one before deciding. With a discriminator it reads the tag field, resolves the target schema from the mapping in one step, and validates only that branch.
Can I use anyOf instead of oneOf for a payment method field?
No. anyOf passes if one or more branches match, which means a payload can satisfy CardPayment and BankPayment simultaneously. oneOf requires exactly one match, which is the correct semantics for a discriminated union where the payload must be one specific variant.
Does the discriminator mapping have to list every branch?
The mapping is optional in the spec but required for reliable behavior. Without it, validators derive the mapping from the $ref component name, which breaks as soon as your component names and tag values diverge — which they do in multi-team repos.
How do I add a new payment method without a breaking change?
Add the new subschema to components.schemas, append it to the oneOf array, add its tag value to the discriminator mapping, and use an additive enum value in the tag property. Existing clients that do not know the new variant receive an unknown tag and should ignore it if your consumer contracts are written defensively.
Why does openapi-typescript generate a useless union type?
openapi-typescript 7 does generate the union correctly when a discriminator is present. The useless Record<string, never> | object union is what you get when oneOf branches have no shared required property or when the discriminator is missing. Fix: add the discriminator and ensure each branch declares the tag as a required property with a constant enum value.
Should I set additionalProperties: false on each oneOf branch?
Yes. Without it, a CardPayment object passes validation against WalletPayment if WalletPayment’s required fields happen to be a subset of CardPayment’s. Closing each branch with additionalProperties: false makes oneOf genuinely exclusive and prevents silent cross-branch matches.