HomeBlog → JSON Schema oneOf vs anyOf vs allOf

JSON Schema oneOf vs anyOf vs allOf — Composition Keywords Explained (2026)

📅 Updated April 2026 ⏱ 15 min read 🛠 Developer guide
SG
By Saurabh Goyal · Independent Software Developer
Builds JSON Web Tools · GitHub · About the author

The composition keywords oneOf, anyOf, and allOf are the most powerful — and most misused — features of JSON Schema. They let you describe data shapes that are hard to express with simple type and properties alone: a payment that is either a credit card or a bank transfer, a value that may be either a string or an array of strings, a configuration object that extends a base schema with additional restrictions. Used correctly, they keep your schemas DRY and your error messages precise. Used incorrectly, they produce schemas that match the wrong data, fail with cryptic errors, or cause subtle validator-specific incompatibilities. This guide walks through what each keyword actually means, when to choose one over another, the discriminator pattern that makes oneOf usable in production, and the gotchas that bite teams shipping APIs against generated code.

Test your schema as you read

Paste any JSON Schema and sample data into the validator — see exactly which branch matches and why.

Open JSON Schema Validator →

The 30-Second Summary

Before diving into nuance, the difference at a glance:

KeywordLogicMatch requirementTypical use
allOfANDData must satisfy every sub-schemaCombine a base schema with additional restrictions; schema inheritance.
anyOfORData must satisfy at least one sub-schemaPermissive union — value can be a string OR a number; overlap is fine.
oneOfXORData must satisfy exactly one sub-schemaMutually exclusive variants — payment is credit card XOR bank transfer.

Mental model: allOf is intersection. anyOf is union (with overlap allowed). oneOf is symmetric difference at the validation level — exactly one branch can match.

allOf — Combining Constraints

allOf is conceptually the simplest: every sub-schema must validate. The most common reason to use it is to combine a shared base schema with additional restrictions, expressed as schema inheritance:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "Person": {
      "type": "object",
      "properties": {
        "name": { "type": "string" },
        "email": { "type": "string", "format": "email" }
      },
      "required": ["name", "email"]
    }
  },
  "allOf": [
    { "$ref": "#/$defs/Person" },
    {
      "type": "object",
      "properties": {
        "employeeId": { "type": "string", "pattern": "^EMP-\\d{6}$" }
      },
      "required": ["employeeId"]
    }
  ]
}

An Employee here must satisfy both the Person base (name + email) and the additional employeeId requirement. Validators apply each sub-schema independently and combine the failures.

The unevaluatedProperties trap

One subtle gotcha: if you set "additionalProperties": false inside the base schema, the base sub-schema fails on the employeeId field because that property is not declared in the base. The same is true if additionalProperties: false is set in the outer schema — it does not "see" properties declared inside allOf branches. The fix in Draft 2019-09 and later is unevaluatedProperties, which considers all properties declared anywhere in the composed schema:

{
  "allOf": [
    { "$ref": "#/$defs/Person" },
    {
      "type": "object",
      "properties": { "employeeId": { "type": "string" } },
      "required": ["employeeId"]
    }
  ],
  "unevaluatedProperties": false
}

This rejects unknown properties without breaking the inheritance pattern.

anyOf — Permissive Unions

Use anyOf when data may match multiple sub-schemas and the validator should accept anything that matches at least one. The classic case is a value that can be either of two types:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "anyOf": [
    { "type": "string", "minLength": 1 },
    { "type": "array", "items": { "type": "string" } }
  ]
}

This accepts "alpha", ["alpha", "beta"], but rejects 42 or [] (empty array fails minLength on string branch and... wait, does it?).

Read carefully: the empty array [] passes the array branch because items only constrains array elements, and an empty array has no elements to fail. anyOf only needs one branch to pass. If you want to reject empty arrays, add "minItems": 1 to the array branch.

anyOf is faster than oneOf

Most validators short-circuit anyOf on the first match — they stop checking remaining branches as soon as one passes. oneOf cannot short-circuit because it needs to confirm exactly one match, which means every branch is evaluated even when the first one passes. For schemas with 5+ branches, this can be a real performance difference on hot validation paths.

oneOf — Mutually Exclusive Variants

oneOf requires exactly one sub-schema to validate. It's the right tool for tagged unions where data takes one shape or another, never both:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "type": { "const": "credit_card" },
        "card_number": { "type": "string", "pattern": "^\\d{16}$" },
        "cvv": { "type": "string", "pattern": "^\\d{3,4}$" }
      },
      "required": ["type", "card_number", "cvv"]
    },
    {
      "type": "object",
      "properties": {
        "type": { "const": "bank_transfer" },
        "iban": { "type": "string", "pattern": "^[A-Z]{2}\\d{2}[A-Z0-9]{1,30}$" },
        "bic": { "type": "string" }
      },
      "required": ["type", "iban", "bic"]
    }
  ]
}

Each branch carries a const on the type field that distinguishes it from the other. This is the discriminator pattern — and it's the single most important technique to use oneOf in production.

The Discriminator Pattern

Without a discriminator, oneOf validation produces terrible error messages. If you submit a partial credit card object, the validator typically reports something like:

"Data did not match any oneOf branch" — and lists 5 errors per branch, each listing every property that didn't match. The user has no idea which branch they were trying to match.

With a discriminator, the validator can immediately identify the intended branch from the type field and report errors only for that branch:

"For type=credit_card, expected property cvv to be a string of length 3 or 4."

Discriminator in pure JSON Schema

Pure JSON Schema doesn't have a discriminator keyword — you achieve the effect by adding const to a property in each branch. Some validators (Ajv with the discriminator option, OpenAPI validators) recognize this pattern and use it to short-circuit branch selection.

Discriminator in OpenAPI 3.x

OpenAPI formalizes the pattern with an explicit discriminator object:

components:
  schemas:
    Payment:
      oneOf:
        - $ref: '#/components/schemas/CreditCard'
        - $ref: '#/components/schemas/BankTransfer'
      discriminator:
        propertyName: type
        mapping:
          credit_card: '#/components/schemas/CreditCard'
          bank_transfer: '#/components/schemas/BankTransfer'

Code generators (openapi-generator, NSwag, openapi-typescript) use the discriminator to emit clean tagged-union types in the target language: a TypeScript discriminated union, a C# polymorphic record, a Java sealed interface. Without the discriminator, generators fall back to producing a single merged type with all properties optional — a much weaker type.

How to Decide: A Simple Flowchart

  1. Will the data ever satisfy more than one sub-schema simultaneously? If no (mutual exclusivity guaranteed by design) → oneOf with a discriminator.
  2. Are you combining schemas to add restrictions? (base + extension) → allOf.
  3. Do you want "this OR that" without caring about overlap?anyOf.
  4. Are sub-schemas the same shape with different field values?oneOf with const discriminator on the differing field.
  5. Are you tempted to use oneOf without a discriminator? → Switch to anyOf. It validates faster and the error messages are no worse.

Common Pitfalls

1. Multiple oneOf branches matching

The most common oneOf bug. Two branches accidentally accept the same input — both succeed, validation fails because oneOf requires exactly one. Fix: add a discriminator const, or remove ambiguous fields, or switch to anyOf.

2. allOf with conflicting required fields

If two allOf branches require different values for the same field (e.g., one requires type: "A", another requires type: "B"), nothing can ever validate. The schema is technically valid but matches no data. Validators usually do not warn; the error only appears when you test with sample data.

3. Forgetting that const is exact match

"const": "credit_card" matches only the literal string. Numbers, booleans, and trimmed/cased variations all fail. If your API receives "Credit_Card" from a client, it fails the discriminator silently and falls into the "no branch matched" error.

4. Using oneOf for nullable fields

You may see schemas like { "oneOf": [{ "type": "string" }, { "type": "null" }] }. This works but is verbose and doesn't compose well. Prefer { "type": ["string", "null"] } in JSON Schema, which is shorter and explicit. Note that OpenAPI 3.0 (not 3.1) does not support type arrays — use nullable: true there.

5. Composition keywords inside if/then/else

Combining oneOf with if/then/else can produce unintuitive results because if always succeeds in determining which branch to take, even if the data is otherwise invalid. Test the schema with both passing and failing samples to confirm behavior.

Behavior Across Draft Versions

The composition keywords have been stable since Draft 4, but related features have evolved:

FeatureDraft 4Draft 72019-09 / 2020-12
oneOf / anyOf / allOf
if / then / else
unevaluatedProperties✓ (recommended)
$ref with siblings allowed
Type arrays ("type": ["string", "null"])

If you can target 2019-09 or later, unevaluatedProperties resolves most of the historical pain around composing closed schemas.

Code Generation: Zod, TypeBox, TypeScript

Composition keywords don't always survive code generation cleanly. Here's how the popular runtime validators map them:

Zod

// oneOf with discriminator → z.discriminatedUnion (preferred for performance)
const Payment = z.discriminatedUnion("type", [
  z.object({ type: z.literal("credit_card"), card_number: z.string() }),
  z.object({ type: z.literal("bank_transfer"), iban: z.string() }),
]);

// anyOf → z.union (no discriminator optimization)
const Value = z.union([z.string(), z.array(z.string())]);

// allOf → z.intersection or .merge() for objects
const Employee = Person.merge(z.object({ employeeId: z.string() }));

TypeBox

import { Type } from "@sinclair/typebox";

const Payment = Type.Union([
  Type.Object({ type: Type.Literal("credit_card"), card_number: Type.String() }),
  Type.Object({ type: Type.Literal("bank_transfer"), iban: Type.String() }),
]);

// allOf
const Employee = Type.Intersect([Person, Type.Object({ employeeId: Type.String() })]);

If you're authoring schemas from scratch and consume them in TypeScript, generating types from JSON Schema with our JSON to TypeScript converter typically produces the cleanest result.

Real Examples from Production APIs

Stripe-style payment events

{
  "type": "object",
  "properties": {
    "type": { "type": "string" },
    "data": {
      "oneOf": [
        { "$ref": "#/$defs/PaymentIntent" },
        { "$ref": "#/$defs/Charge" },
        { "$ref": "#/$defs/Refund" }
      ]
    }
  },
  "discriminator": { "propertyName": "type" }
}

Polymorphic content blocks (CMS)

{
  "type": "array",
  "items": {
    "oneOf": [
      { "$ref": "#/$defs/HeroBlock" },
      { "$ref": "#/$defs/TextBlock" },
      { "$ref": "#/$defs/ImageBlock" },
      { "$ref": "#/$defs/CTABlock" }
    ]
  }
}

Configuration with environment-specific overrides

{
  "allOf": [
    { "$ref": "#/$defs/BaseConfig" },
    {
      "if": { "properties": { "env": { "const": "production" } } },
      "then": { "required": ["apiKey", "sslCertPath"] }
    }
  ]
}

Validating with the JSON Schema Validator

The fastest way to develop these schemas is iteratively: paste your schema and sample data into a validator, watch which branches match, and refine. Try it yourself:

Validate your composition schema

Test oneOf, anyOf, and allOf schemas against sample JSON in your browser. No data leaves your device.

Open JSON Schema Validator →

Key Takeaways

Further Reading

Frequently Asked Questions

What is the difference between oneOf, anyOf, and allOf?+

oneOf requires the data to validate against exactly one of the listed sub-schemas (XOR). anyOf requires it to validate against at least one (OR). allOf requires it to validate against every listed sub-schema (AND). Choose oneOf when sub-schemas are mutually exclusive, anyOf when overlap is acceptable, and allOf to combine constraints.

When should I use oneOf vs anyOf?+

Use oneOf when the sub-schemas are designed to be mutually exclusive — for example, a payment object that is either a credit card or a bank transfer but never both. Use anyOf when sub-schemas can overlap and the data only needs to satisfy one — for example, a value that may be either a string OR a number. anyOf is also faster to validate because it short-circuits on the first match.

What is the discriminator pattern?+

The discriminator pattern uses a known property (like type or kind) to tell a validator which oneOf branch to apply, without having to try every branch. OpenAPI 3.x formalizes this with a discriminator keyword. In raw JSON Schema, the same effect is achieved with const inside each oneOf branch. This makes errors more readable and validation faster.

Can allOf be used to extend a base schema?+

Yes — allOf is the canonical way to express schema inheritance. Define a base schema as a $ref, then add restrictions in a sibling sub-schema inside allOf. The resulting schema validates only data that satisfies both. Use unevaluatedProperties: false in Draft 2019-09+ to enforce strict closure across allOf branches.

Why does my oneOf schema match multiple branches?+

oneOf fails when more than one sub-schema matches. The most common cause is that branches are not actually mutually exclusive. Fix this by adding required + const constraints (the discriminator pattern), or by switching to anyOf if true mutual exclusivity is not required.