# Model polymorphism in OpenAPI 3.1: oneOf, anyOf, allOf, and discriminator without breaking clients

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

| Keyword | Validation rule                                           | Use for                                              |
|---------|-----------------------------------------------------------|------------------------------------------------------|
| `allOf` | Instance must validate against **every** subschema        | Composition, inheritance, merging reusable fragments |
| `anyOf` | Instance must validate against **at least one** subschema | Loose union where overlap is allowed                 |
| `oneOf` | Instance must validate against **exactly one** subschema  | Mutually 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.

``` yaml
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.

``` yaml
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.

``` yaml
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.

``` yaml
# 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:

``` ts
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](https://www.powerduck.com/app/?ref=powerduck.com). Polymorphism is easiest to maintain when variants reuse shared fragments; the extraction rules are in the [reusable JSON Schema components guide](https://www.powerduck.com/blog/reusable-json-schema-components-openapi-ref/).

