Skip to main content

Choosing Between oneOf, anyOf and allOf

Problem: three keywords look interchangeable, produce wildly different error messages, and generate different TypeScript. Picking the wrong one gives you a schema that validates correctly and reports failures nobody can act on — or, worse, one that rejects payloads it should accept. This guide is part of JSON Schema Keywords for API Payloads.

The Distinction in One Sentence Each

  • allOf — the payload must satisfy every branch. Use it for composition and inheritance.
  • oneOf — the payload must satisfy exactly one branch. Use it for genuinely exclusive alternatives.
  • anyOf — the payload must satisfy at least one branch. Use it when alternatives may overlap.
How many branches must accept the payload allOf requires the payload to sit in the intersection of every branch; oneOf requires it to sit in exactly one branch and outside the overlap; anyOf requires it to sit in at least one, overlap included. allOf valid must satisfy both oneOf invalid exactly one — the overlap fails anyOf valid at least one — overlap is fine

Root Cause of the Usual Bug: Overlapping oneOf Branches

oneOf is the keyword teams reach for by default, and it is the one that fails in the least obvious way. Two branches that both accept a payload produce a validation error even though the payload is exactly what you intended.

# BROKEN: both branches accept { "email": "a@b.com", "phone": "+44…" }
# because neither closes the object.
Contact:
  oneOf:
    - type: object
      required: [email]
      properties: { email: { type: string } }
    - type: object
      required: [phone]
      properties: { phone: { type: string } }
$ ajv validate -s contact.yaml -d both.json --spec=draft2020
data must match exactly one schema in oneOf

That message tells you nothing about which fields were wrong, because nothing was wrong with the fields. Two fixes exist, and the right one depends on the intent:

# Intent A: a contact may carry either or both — use anyOf.
Contact:
  anyOf:
    - { type: object, required: [email], properties: { email: { type: string } } }
    - { type: object, required: [phone], properties: { phone: { type: string } } }

# Intent B: exactly one channel is allowed — close the branches so they cannot overlap.
Contact:
  oneOf:
    - type: object
      required: [email]
      additionalProperties: false
      properties: { email: { type: string } }
    - type: object
      required: [phone]
      additionalProperties: false
      properties: { phone: { type: string } }

Use allOf for Composition, and Close It Correctly

allOf is how you express “an extension of that, plus these fields”. The trap is the closing keyword:

components:
  schemas:
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }

    ValidationProblem:
      allOf:
        - $ref: '#/components/schemas/Problem'
      # NOT additionalProperties — it cannot see Problem's fields and would
      # reject `type`, `title` and `status` as unknown.
      unevaluatedProperties: false
      required: [errors]
      properties:
        errors:
          type: array
          minItems: 1
          items:
            type: object
            required: [path, code]
            additionalProperties: false
            properties:
              path: { type: string }
              code: { type: string }

Note that additionalProperties: false on the innermost errors item is correct — that object is not composed, so the keyword sees everything it needs to.

Add a Discriminator to Any Real oneOf

A oneOf over meaningfully different shapes should always carry a discriminator. It converts a search into a lookup, produces a precise error, and generates a union the compiler can narrow.

A discriminator turns a scan into a lookup Without a discriminator every branch is evaluated for every payload, so cost grows with the number of branches. With one, a single branch is evaluated regardless of how many exist. number of branches in the union work per payload bare oneOf — every branch tried discriminated — one branch, always
PaymentMethod:
  oneOf:
    - $ref: '#/components/schemas/CardPayment'
    - $ref: '#/components/schemas/BankPayment'
    - $ref: '#/components/schemas/WalletPayment'
  discriminator:
    propertyName: kind
    mapping:
      card: '#/components/schemas/CardPayment'
      bank: '#/components/schemas/BankPayment'
      wallet: '#/components/schemas/WalletPayment'

CardPayment:
  type: object
  required: [kind, last4, expiry]
  additionalProperties: false
  properties:
    kind: { type: string, const: card }      # const, not enum with one member
    last4: { type: string, pattern: '^[0-9]{4}$' }
    expiry: { type: string, pattern: '^[0-9]{2}/[0-9]{2}$' }

const rather than a single-member enum matters for generated types: most generators emit a literal type for const and a widened string for enum, and the literal is what makes the union discriminated. The downstream effects are covered in modeling polymorphic types with oneOf.

What each choice costs at the two moments that matter For a bad payload, bare oneOf reports a useless union failure while a discriminated oneOf names the field. For code generation, allOf yields an intersection, discriminated oneOf a narrowable union, and anyOf an untagged union. keyword error on a bad payload generated TypeScript allOf names the failing branch and field A & B — an intersection oneOf, no discriminator "must match exactly one schema" A | B — cannot narrow oneOf + discriminator names the field inside one branch tagged union — narrows anyOf lists every branch's failures A | B — cannot narrow

Verification

// composition.test.ts
const validate = ajv.compile(paymentMethodSchema);

test("a discriminated union reports the offending field, not the union", () => {
  expect(validate({ kind: "card", last4: 1234, expiry: "12/28" })).toBe(false);
  const err = validate.errors![0];
  expect(err.instancePath).toBe("/last4");        // not "" — the union root
  expect(err.keyword).toBe("type");
});

test("an unknown discriminator value fails cleanly", () => {
  expect(validate({ kind: "crypto", address: "…" })).toBe(false);
  expect(validate.errors!.some((e) => e.keyword === "const" || e.keyword === "discriminator")).toBe(true);
});

test("anyOf accepts a payload matching both branches", () => {
  const contact = ajv.compile(contactAnyOfSchema);
  expect(contact({ email: "a@b.com", phone: "+441234567890" })).toBe(true);
});

test("oneOf with closed branches rejects the both case", () => {
  const contact = ajv.compile(contactOneOfClosedSchema);
  expect(contact({ email: "a@b.com", phone: "+441234567890" })).toBe(false);
});

The first test is the one that proves the discriminator is working: without it, instancePath is the empty string and the error is useless to a form.

Edge Cases and Caveats

  • oneOf over primitives. oneOf: [{type: string}, {type: number}] works and cannot overlap, but a union of primitives usually signals an under-designed field. Prefer one type plus a documented coercion at the edge.
  • allOf with conflicting constraints. Two branches requiring maxLength: 10 and minLength: 20 produce a schema nothing can satisfy, and most validators will not warn. Ajv’s strict mode catches some of these; a test with a known-good payload catches the rest.
  • Discriminator without required. If the discriminator property is not in the branch’s required list, a payload omitting it falls through to the generic union error. Always require the tag.
  • Nested composition and error depth. A oneOf inside an allOf inside a oneOf produces errors that name paths four levels deep. Two levels is the practical readability limit.
  • OpenAPI 3.0 discriminator semantics. In 3.0 the discriminator is a documentation hint that most validators ignore, so the branch resolution behaviour described here depends on 3.1 or a validator that implements it. Check your specific tool rather than assuming.

Composition and Backward Compatibility

Composition keywords interact with API evolution in ways that are worth knowing before a union is published, because each one has a different answer to “can I add a branch later?”.

Adding a branch to a oneOf is breaking for a strict consumer and safe for a lenient one. A generated client that switched exhaustively over three payment kinds fails to compile against four; a client that falls through to a default handles it. Because you cannot know which your consumers wrote, treat adding a union member as a change worth announcing, even though no existing payload becomes invalid.

Removing a branch is unambiguously breaking, in the same way as removing an endpoint: payloads that used to validate no longer do.

Adding a branch to an anyOf is safe, because nothing that validated before stops validating. That asymmetry is a genuine reason to prefer anyOf for open-ended sets — supported authentication methods, accepted notification channels — where you expect to add members.

Adding a branch to an allOf is a tightening, and therefore breaking for requests and safe for responses, following the usual asymmetry: a new constraint rejects payloads that were previously accepted.

The practical guidance that falls out: model an open set with anyOf or with a discriminated oneOf whose discriminator is documented as extensible, and reserve closed oneOf unions for cases where the set genuinely will not grow.

Debugging a Union That Will Not Resolve

When a union fails and the error is useless, three checks find the cause quickly. Validate the payload against each branch individually — the branch that passes is the one you expected, and if two pass you have found the overlap. Check that every branch is closed, since an open branch accepts almost anything. And confirm the discriminator property is in each branch’s required array, because a discriminator the validator cannot read is a discriminator that does not exist.

A Rule of Thumb Worth Memorising

When a union is genuinely a set of alternative shapes, use oneOf with a discriminator and closed branches — that combination is deterministic, generates a narrowable type, and produces errors inside the right branch. Reach for anyOf only when overlap is legitimate, and for allOf only when composing rather than choosing.

Frequently Asked Questions

When is anyOf better than oneOf?

When the alternatives legitimately overlap and you do not care which one matched — a contact that may carry an email, a phone number, or both. oneOf would reject the both case, because exactly one branch must match.

Why does oneOf fail when two branches both match?

Because oneOf means exactly one. Two permissive branches — for example two objects that both allow extra properties — will both accept the same payload, and the validator reports a failure that looks nothing like the real problem. Closing the branches or adding a discriminator fixes it.

Does allOf merge the subschemas?

Logically, yes: the payload must satisfy every branch, and required arrays from all branches apply together. Physically it does not produce a merged schema, which is why additionalProperties inside an allOf misbehaves and unevaluatedProperties is the right closing keyword.

Which keyword generates the best TypeScript?

allOf produces an intersection, which is usually what you want for inheritance. oneOf with a discriminator produces a tagged union the compiler can narrow. Bare oneOf and anyOf both produce unions that need hand-written type guards.

Can I nest composition keywords?

You can, and two levels is usually the practical limit before error messages become unreadable. If you need three, the payload is probably better expressed as separate endpoints or a discriminated union with more members.