An endpoint that returns "a payment" really returns one of several distinct shapes: a card has a last-four and brand, a bank transfer has an IBAN, a wallet has a provider id. Flatten that into one object with every field optional and clients drown in nullable fields; model it correctly and a generated TypeScript client becomes a tagged union the compiler can narrow. The difference comes down to three keywords and one discriminator.

What the combinators actually mean

KeywordValidation ruleUse for
allOfInstance must validate against every subschemaComposition, inheritance, merging reusable fragments
anyOfInstance must validate against at least one subschemaLoose union where overlap is allowed
oneOfInstance must validate against exactly one subschemaMutually exclusive variants, the polymorphism case

The word "exactly" is the whole point. oneOf fails validation if an instance matches two branches, which is what makes variants safe. anyOf is permissive: an object carrying fields from two variants is valid. Reach for oneOf for a true tagged union and allOf to assemble schemas; treat anyOf as the exception, not the default.

A discriminated response

Give every variant a common tag property and point discriminator at it. The response below returns one of three payment methods.

components:
  schemas:
    Payment:
      type: object
      discriminator:
        propertyName: method
        mapping:
          card: '#/components/schemas/CardPayment'
          bank_transfer: '#/components/schemas/BankTransfer'
          wallet: '#/components/schemas/WalletPayment'
      oneOf:
        - $ref: '#/components/schemas/CardPayment'
        - $ref: '#/components/schemas/BankTransfer'
        - $ref: '#/components/schemas/WalletPayment'

    CardPayment:
      type: object
      required: [method, brand, last4]
      properties:
        method: { type: string, enum: [card] }
        brand: { type: string, example: visa }
        last4: { type: string, pattern: '^[0-9]{4}$' }

    BankTransfer:
      type: object
      required: [method, iban]
      properties:
        method: { type: string, enum: [bank_transfer] }
        iban: { type: string, example: DE89370400440532013000 }

    WalletPayment:
      type: object
      required: [method, provider, walletId]
      properties:
        method: { type: string, enum: [wallet] }
        provider: { type: string, enum: [paypal, apple_pay, google_pay] }
        walletId: { type: string, format: uuid }

The mapping is optional when the tag value equals the schema name, but state it explicitly whenever your wire values are snake_case, namespaced, or otherwise different from component names. Relying on implicit matching is the most common source of "the discriminator does not resolve" bugs.

Requests use the same shape

A create endpoint accepts the same union in the request body. Add a top-level required: [method] discipline through each variant so the tag is always present; without it, the server cannot dispatch.

paths:
  /payments:
    post:
      operationId: createPayment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Payment'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
        '422': { $ref: '#/components/responses/ValidationError' }

Use allOf for composition, not for branches

allOf merges schemas. It is the right tool for "every entity has id and created_at, and an invoice adds its own fields," or for extending a shared base with a constraint.

Invoice:
  allOf:
    - $ref: '#/components/schemas/EntityBase'
    - type: object
      required: [number, status]
      properties:
        number: { type: string }
        status: { $ref: '#/components/schemas/InvoiceStatus' }
        amount: { $ref: '#/components/schemas/Money' }

Do not encode polymorphic branches with allOf; an instance is then expected to satisfy every branch at once, which is the opposite of a union.

Do not wrap a single nullable type in oneOf

A frequent misuse is a two-branch oneOf purely to express nullability. In OpenAPI 3.1 (JSON Schema 2020-12) nullability is a type, not a union of object shapes.

# 3.1: prefer this
discount:
  type: [object, 'null']
  allOf:
    - $ref: '#/components/schemas/Discount'

# Not this, which says "either a discount object or the string null"
discount:
  oneOf:
    - $ref: '#/components/schemas/Discount'
    - type: string
      enum: ['null']

Reserve oneOf for genuinely distinct object variants. Mixing nullability into it pollutes the generated union.

What codegen produces

With a discriminator, a TypeScript generator emits a tagged union and the caller narrows on the tag with no casts:

type Payment = CardPayment | BankTransfer | WalletPayment;

function describe(p: Payment): string {
  switch (p.method) {
    case "card":
      return p.brand.toUpperCase() + " " + p.last4; // p is CardPayment
    case "bank_transfer":
      return "IBAN " + p.iban.slice(-4);           // p is BankTransfer
    case "wallet":
      return p.provider;                            // p is WalletPayment
  }
}

Drop the discriminator and the same generator typically emits Payment = CardPayment & BankTransfer & WalletPayment-ish ambiguity or an untyped any, because oneOf without a tag cannot be resolved at runtime. The discriminator is what turns a schema feature into a usable SDK.

Examples and mocks must cover every branch

A union with one example leaves docs, mock servers, and AI callers biased to that one variant. Provide one example per branch and, where the tool supports it, a response example keyed to each tag. A mock should be able to return any branch on demand so the client tests every switch arm. AI agents reading the spec use the discriminator value as the exact field to set; when the tag is missing or the branches overlap, models either omit variant-specific fields or merge two of them.

Common mistakes

  • anyOf used where variants are mutually exclusive, letting mixed objects through.
  • No discriminator, forcing generators to emit untyped results.
  • Implicit mapping that breaks when wire values differ from schema names.
  • Branches that overlap (two variants both valid for the same payload), making oneOf ambiguous. Keep each variant uniquely identifiable by its tag.
  • Forgetting the tag in required, so an untagged body cannot be dispatched.
  • A single nullable field dressed up as a two-branch oneOf.

Checklist

  1. Mutually exclusive variants use oneOf; composition uses allOf; leave anyOf for genuinely overlapping input.
  2. Every variant carries a required tag property and discriminator.propertyName points at it.
  3. mapping is explicit when tag values differ from component names.
  4. Each branch is uniquely identifiable; no two branches validate the same payload.
  5. Nullability uses the 3.1 type array, not a fake variant.
  6. One example per branch exists; mocks and generated clients are tested on every arm.

Get these right and polymorphism becomes a type-safe feature instead of a pile of optional fields.

Define a discriminated union, generate the tagged-union client, and return each variant from a mock to test every branch, in the browser app. Polymorphism is easiest to maintain when variants reuse shared fragments; the extraction rules are in the reusable JSON Schema components guide.