Skip to main content

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: false so 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

BeforeoneOf 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.

AfteroneOf 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.

oneOf discriminator resolution for PaymentMethod The PaymentMethod oneOf reads the method tag field. A discriminator mapping routes card to CardPayment, bank to BankPayment, and wallet to WalletPayment. Each branch declares additionalProperties: false and a constant enum tag, so validation touches only one branch. PaymentMethod oneOf + discriminator: method

reads method tag → resolves one branch

CardPayment method: enum [card] cardNumber: string expiryMonth: integer expiryYear: integer cvv: string additionalProperties: false BankPayment method: enum [bank] routingNumber: string accountNumber: string accountHolderName: string additionalProperties: false WalletPayment method: enum [wallet] walletProvider: enum walletId: uuid additionalProperties: false

Validator reads method, resolves mapping, validates one branch — O(1) Without discriminator: all three branches evaluated — errors from all, ambiguous result

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.