JSON Schema Composition: allOf, oneOf, anyOf, if/then/else & Discriminated Unions

Last updated:

JSON Schema's four composition keywords — allOf, oneOf, anyOf, and if/then/else — combine schemas to model complex validation rules without duplication. allOf requires a value to satisfy ALL listed schemas simultaneously — the TypeScript equivalent is intersection A & B, commonly used to extend a base schema with additional properties. oneOf requires EXACTLY ONE schema to match — if two schemas both validate, oneOf fails; use it for strictly mutually exclusive variants. anyOf requires AT LEAST ONE schema to match — the most permissive combinator, equivalent to TypeScript union A | B. if/then/else applies conditional validation: when the if schema validates, the then constraints apply; otherwise else constraints apply — more efficient than oneOf for conditional required fields. Discriminated unions use oneOf with a const discriminator field to model polymorphic JSON. This guide covers all four composition keywords with real examples, discriminated unions, extending base schemas with $ref + allOf, and common mistakes.

allOf: Intersection Schemas and Extending Base Types

allOf takes an array of schemas and requires the value to satisfy every one of them. This is the JSON Schema equivalent of TypeScript's A & B intersection type. The most common use is extending a base schema with additional required properties — a pattern used widely in OpenAPI component schemas and event-driven architectures where all events share a common envelope.

// ── Basic allOf: value must satisfy ALL schemas ─────────────────
{
  "allOf": [
    { "type": "object" },
    { "required": ["id"], "properties": { "id": { "type": "string" } } },
    { "required": ["name"], "properties": { "name": { "type": "string" } } }
  ]
}

// Valid: { "id": "1", "name": "Alice" }   ✓ — all 3 schemas pass
// Invalid: { "id": "1" }                  ✗ — fails schema 3 (name required)

// ── Extending a base schema ──────────────────────────────────────
// Define a base event envelope in $defs
{
  "$defs": {
    "BaseEvent": {
      "type": "object",
      "required": ["eventId", "timestamp", "version"],
      "properties": {
        "eventId":   { "type": "string", "format": "uuid" },
        "timestamp": { "type": "string", "format": "date-time" },
        "version":   { "type": "integer", "minimum": 1 }
      }
    }
  },

  // OrderCreated extends BaseEvent
  "title": "OrderCreated",
  "allOf": [
    { "$ref": "#/$defs/BaseEvent" },
    {
      "required": ["orderId", "total", "items"],
      "properties": {
        "orderId": { "type": "string" },
        "total":   { "type": "number", "minimum": 0 },
        "items":   {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "required": ["productId", "quantity"],
            "properties": {
              "productId": { "type": "string" },
              "quantity":  { "type": "integer", "minimum": 1 }
            }
          }
        }
      }
    }
  ]
}

// Valid value:
// {
//   "eventId": "550e8400-e29b-41d4-a716-446655440000",
//   "timestamp": "2026-05-28T10:00:00Z",
//   "version": 1,
//   "orderId": "ord_001",
//   "total": 49.99,
//   "items": [{ "productId": "sku-42", "quantity": 2 }]
// }

// ── allOf with type narrowing ────────────────────────────────────
// Combine type + format constraints from multiple schemas
{
  "allOf": [
    { "type": "string" },
    { "minLength": 3, "maxLength": 50 },
    { "pattern": "^[a-zA-Z0-9_-]+$" }
  ]
}
// The value must be a string AND 3-50 chars AND match the pattern

// ── allOf with additionalProperties warning ──────────────────────
// PROBLEM: if BaseEvent has additionalProperties: false,
// it blocks extra properties added in the second allOf branch.
// SOLUTION: use unevaluatedProperties: false at the top level instead.
{
  "allOf": [
    { "$ref": "#/$defs/BaseEvent" },
    {
      "properties": {
        "orderId": { "type": "string" }
      }
    }
  ],
  "unevaluatedProperties": false
  // Works correctly — considers properties from all allOf branches
}

When using allOf with $ref to extend a base schema, avoid setting additionalProperties: false on the base schema itself — it cannot see the properties defined in the other allOf branches, causing them to be rejected as additional. Instead, use unevaluatedProperties: false at the top level of the composed schema. This keyword, introduced in Draft 2019-09, is composition-aware and correctly evaluates all properties across all allOf branches before enforcing the constraint.

oneOf: Strict Mutual Exclusion and Discriminated Unions

oneOf requires EXACTLY ONE schema in the array to validate. If zero schemas match, or if 2 or more schemas match, validation fails. This strict mutual exclusivity makes oneOf the right choice when variants are truly disjoint — particularly for discriminated unions where a type field determines which shape the object must have.

// ── Basic oneOf: exactly one schema must match ──────────────────
{
  "oneOf": [
    { "type": "string" },
    { "type": "integer" }
  ]
}

// Valid:   "hello"  ✓ — matches schema 1 only
// Valid:   42       ✓ — matches schema 2 only
// Invalid: 3.14     ✗ — matches neither (3.14 is not integer)
// Invalid: true     ✗ — matches neither

// ── Discriminated union with oneOf ───────────────────────────────
// A payment can be either credit card OR bank transfer — never both
{
  "$defs": {
    "CreditCard": {
      "type": "object",
      "required": ["method", "cardNumber", "expiry", "cvv"],
      "properties": {
        "method":     { "const": "credit_card" },
        "cardNumber": { "type": "string", "pattern": "^[0-9]{16}$" },
        "expiry":     { "type": "string", "pattern": "^(0[1-9]|1[0-2])/[0-9]{2}$" },
        "cvv":        { "type": "string", "pattern": "^[0-9]{3,4}$" }
      },
      "additionalProperties": false
    },
    "BankTransfer": {
      "type": "object",
      "required": ["method", "iban", "bic"],
      "properties": {
        "method": { "const": "bank_transfer" },
        "iban":   { "type": "string", "pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7,19}$" },
        "bic":    { "type": "string", "pattern": "^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$" }
      },
      "additionalProperties": false
    },
    "CryptoPayment": {
      "type": "object",
      "required": ["method", "walletAddress", "currency"],
      "properties": {
        "method":        { "const": "crypto" },
        "walletAddress": { "type": "string" },
        "currency":      { "type": "string", "enum": ["BTC", "ETH", "USDC"] }
      },
      "additionalProperties": false
    }
  },

  "title": "Payment",
  "oneOf": [
    { "$ref": "#/$defs/CreditCard" },
    { "$ref": "#/$defs/BankTransfer" },
    { "$ref": "#/$defs/CryptoPayment" }
  ]
}

// Valid: { "method": "credit_card", "cardNumber": "4111111111111111", "expiry": "12/28", "cvv": "123" }
// Invalid: { "method": "credit_card", "iban": "...", "bic": "..." }  — method mismatch

// ── oneOf with non-object types ──────────────────────────────────
// Accept either a positive number or a numeric string
{
  "oneOf": [
    { "type": "number", "minimum": 0 },
    { "type": "string", "pattern": "^[0-9]+(\.[0-9]+)?$" }
  ]
}
// "42.5"  ✓ — matches schema 2 only (string)
// 42.5    ✓ — matches schema 1 only (number)
// "abc"   ✗ — matches neither

The key to making oneOf reliable for discriminated unions is ensuring mutual exclusivity structurally — using const on a type or method field, combined with additionalProperties: false on each branch. With const discriminators, a validator like Ajv can short-circuit after finding the branch whose const matches the input value, making validation O(1) rather than O(N) across all branches.

anyOf: Permissive Union Validation

anyOf requires AT LEAST ONE schema to match and is the most permissive composition keyword. Unlike oneOf, it does not fail when multiple schemas match. This makes it the correct keyword for TypeScript-style union types (A | B), nullable fields, and cases where schemas can legitimately overlap.

// ── Basic anyOf: at least one schema must match ─────────────────
{
  "anyOf": [
    { "type": "string" },
    { "type": "number" }
  ]
}
// "hello"  ✓ — schema 1 matches
// 42       ✓ — schema 2 matches
// null     ✗ — neither matches

// ── Nullable field with anyOf ────────────────────────────────────
// Equivalent to TypeScript: string | null
{
  "anyOf": [
    { "type": "string" },
    { "type": "null" }
  ]
}
// "Alice"  ✓
// null     ✓
// 42       ✗

// ── anyOf for a field accepting multiple value shapes ────────────
{
  "$defs": {
    "StringId": {
      "type": "string",
      "description": "A string identifier like 'user_01'"
    },
    "IntegerId": {
      "type": "integer",
      "minimum": 1,
      "description": "A numeric identifier"
    },
    "UuidId": {
      "type": "string",
      "format": "uuid",
      "description": "A UUID identifier"
    }
  },
  "title": "ResourceId",
  "description": "Accepts string IDs, integer IDs, or UUIDs",
  "anyOf": [
    { "$ref": "#/$defs/StringId" },
    { "$ref": "#/$defs/IntegerId" },
    { "$ref": "#/$defs/UuidId" }
  ]
}

// ── anyOf for object union with shared properties ────────────────
// Notification can be email OR sms OR push — schemas are NOT exclusive
// (a push notification object is valid as long as it matches one of them)
{
  "anyOf": [
    {
      "type": "object",
      "required": ["channel", "email"],
      "properties": {
        "channel": { "const": "email" },
        "email":   { "type": "string", "format": "email" },
        "subject": { "type": "string" }
      }
    },
    {
      "type": "object",
      "required": ["channel", "phoneNumber"],
      "properties": {
        "channel":     { "const": "sms" },
        "phoneNumber": { "type": "string", "pattern": "^\+[1-9][0-9]{7,14}$" }
      }
    },
    {
      "type": "object",
      "required": ["channel", "deviceToken"],
      "properties": {
        "channel":     { "const": "push" },
        "deviceToken": { "type": "string" },
        "title":       { "type": "string" },
        "body":        { "type": "string" }
      }
    }
  ]
}

// ── anyOf with allOf for "at least 2 of 3" style rules ───────────
// Require at least 2 password complexity rules to pass
{
  "type": "string",
  "anyOf": [
    { "pattern": "(?=.*[A-Z])" },
    { "pattern": "(?=.*[0-9])" },
    { "pattern": "(?=.*[^a-zA-Z0-9])" }
  ]
}
// NOTE: This requires ANY 1+ of these. For "at least 2 of 3",
// use allOf with anyOf inside, or custom keywords.

anyOf validators short-circuit after the first matching schema — they do not need to check remaining branches. This makes anyOf faster than oneOf for large union lists, since oneOf must check all branches to count matches. For nullable optional fields in JSON Schema Draft 2020-12, prefer anyOf: [{"type": "T"}, {"type": "null"}] over the Draft 7 pattern of {"type": ["T", "null"]} — the anyOf form is explicit and compatible with OpenAPI 3.1.

if/then/else: Conditional Validation

The if/then/else keywords apply conditional validation without mutual-exclusivity constraints. When the if schema validates the value, the then schema is also applied. When the if schema does not validate, the else schema is applied. The if schema itself never contributes to validation failure — it is purely a selector. This makes if/then/else more efficient and more readable than oneOf for conditional required fields.

// ── Basic if/then/else ───────────────────────────────────────────
// Require zipCode for US, postalCode for everyone else
{
  "type": "object",
  "required": ["country"],
  "properties": {
    "country":    { "type": "string" },
    "zipCode":    { "type": "string", "pattern": "^[0-9]{5}(-[0-9]{4})?$" },
    "postalCode": { "type": "string" }
  },
  "if": {
    "properties": { "country": { "const": "US" } },
    "required": ["country"]
  },
  "then": {
    "required": ["zipCode"],
    "properties": {
      "zipCode": { "type": "string", "pattern": "^[0-9]{5}(-[0-9]{4})?$" }
    }
  },
  "else": {
    "required": ["postalCode"]
  }
}

// { "country": "US", "zipCode": "90210" }           ✓
// { "country": "CA", "postalCode": "K1A 0A6" }      ✓
// { "country": "US", "postalCode": "K1A 0A6" }      ✗ — zipCode required for US

// ── Multiple conditions with allOf + if/then ─────────────────────
// Apply multiple independent conditional rules
{
  "type": "object",
  "allOf": [
    {
      "if": { "properties": { "type": { "const": "individual" } }, "required": ["type"] },
      "then": { "required": ["firstName", "lastName", "dateOfBirth"] }
    },
    {
      "if": { "properties": { "type": { "const": "company" } }, "required": ["type"] },
      "then": { "required": ["companyName", "registrationNumber"] }
    },
    {
      "if": { "required": ["hasVAT"], "properties": { "hasVAT": { "const": true } } },
      "then": { "required": ["vatNumber"], "properties": { "vatNumber": { "type": "string" } } }
    }
  ],
  "properties": {
    "type":               { "type": "string", "enum": ["individual", "company"] },
    "firstName":          { "type": "string" },
    "lastName":           { "type": "string" },
    "dateOfBirth":        { "type": "string", "format": "date" },
    "companyName":        { "type": "string" },
    "registrationNumber": { "type": "string" },
    "hasVAT":             { "type": "boolean" },
    "vatNumber":          { "type": "string" }
  }
}

// ── if without else ──────────────────────────────────────────────
// else is optional — omitting it means no additional constraint
// when the if schema does not match
{
  "type": "object",
  "properties": {
    "discountCode": { "type": "string" },
    "discountAmount": { "type": "number", "minimum": 0, "maximum": 100 }
  },
  "if": {
    "required": ["discountCode"],
    "properties": { "discountCode": { "minLength": 1 } }
  },
  "then": {
    "required": ["discountAmount"]
  }
  // No else: if discountCode is absent, no extra requirements
}

// { "discountCode": "SAVE10", "discountAmount": 10 }  ✓
// { }                                                  ✓ — no discountCode, no constraint
// { "discountCode": "SAVE10" }                        ✗ — discountAmount required

if/then/else is significantly more efficient than using oneOf for conditional required fields, because validators only execute 1 branch (either then or else), while oneOf must run all branches and count matches. Error messages are also cleaner — validators report the then or else failure directly rather than reporting that "0 of N schemas matched" from a oneOf. For schemas that need more than 2 conditional branches, chain multiple if/then blocks inside an allOf.

Discriminated Unions with const Discriminator Fields

Discriminated unions are a pattern for polymorphic JSON — an object that can have one of several distinct shapes, selected by a shared type field. JSON Schema implements them with oneOf + const on the discriminator. Validators like Ajv recognize this pattern and short-circuit after matching the const, making validation nearly as fast as a direct type check.

// ── Discriminated union: notification channels ──────────────────
{
  "$defs": {
    "EmailNotification": {
      "type": "object",
      "required": ["type", "to", "subject", "body"],
      "properties": {
        "type":    { "const": "email" },
        "to":      { "type": "string", "format": "email" },
        "cc":      { "type": "array", "items": { "type": "string", "format": "email" } },
        "subject": { "type": "string", "minLength": 1, "maxLength": 200 },
        "body":    { "type": "string", "minLength": 1 }
      },
      "additionalProperties": false
    },
    "SmsNotification": {
      "type": "object",
      "required": ["type", "to", "message"],
      "properties": {
        "type":    { "const": "sms" },
        "to":      { "type": "string", "pattern": "^\+[1-9][0-9]{7,14}$" },
        "message": { "type": "string", "maxLength": 160 }
      },
      "additionalProperties": false
    },
    "WebhookNotification": {
      "type": "object",
      "required": ["type", "url", "payload"],
      "properties": {
        "type":    { "const": "webhook" },
        "url":     { "type": "string", "format": "uri" },
        "method":  { "type": "string", "enum": ["POST", "PUT", "PATCH"], "default": "POST" },
        "payload": { "type": "object" },
        "headers": { "type": "object", "additionalProperties": { "type": "string" } }
      },
      "additionalProperties": false
    }
  },

  "title": "Notification",
  "oneOf": [
    { "$ref": "#/$defs/EmailNotification" },
    { "$ref": "#/$defs/SmsNotification" },
    { "$ref": "#/$defs/WebhookNotification" }
  ]
}

// Validation examples:
// { "type": "email", "to": "alice@example.com", "subject": "Hello", "body": "Hi!" }  ✓
// { "type": "sms", "to": "+12025551234", "message": "Your code: 847291" }            ✓
// { "type": "email", "to": "+12025551234", "message": "test" }                       ✗ — 'to' is not an email

// ── OpenAPI 3.x discriminator ────────────────────────────────────
// OpenAPI adds a discriminator object alongside oneOf/anyOf
// to hint clients which branch to use for deserialization
{
  "oneOf": [
    { "$ref": "#/components/schemas/Cat" },
    { "$ref": "#/components/schemas/Dog" },
    { "$ref": "#/components/schemas/Fish" }
  ],
  "discriminator": {
    "propertyName": "petType",
    "mapping": {
      "cat":  "#/components/schemas/Cat",
      "dog":  "#/components/schemas/Dog",
      "fish": "#/components/schemas/Fish"
    }
  }
}

// ── Nested discriminated unions ──────────────────────────────────
// An event has a type field, and each type has its own sub-type
{
  "oneOf": [
    {
      "properties": {
        "type":   { "const": "user" },
        "action": { "enum": ["created", "updated", "deleted"] },
        "userId": { "type": "string" }
      },
      "required": ["type", "action", "userId"]
    },
    {
      "properties": {
        "type":    { "const": "order" },
        "action":  { "enum": ["placed", "shipped", "cancelled"] },
        "orderId": { "type": "string" },
        "amount":  { "type": "number", "minimum": 0 }
      },
      "required": ["type", "action", "orderId", "amount"]
    }
  ]
}

For OpenAPI 3.1 schemas, the discriminator object alongside oneOf is not part of JSON Schema validation — it is an OpenAPI hint that tells code generators and clients how to select the correct model class for deserialization. JSON Schema validators like Ajv do not use the discriminator object; they rely entirely on const constraints for discriminated union validation. Always include both the JSON Schema const and the OpenAPI discriminator when writing OpenAPI 3.x specs.

Combining $ref with Composition Keywords

$ref resolves to any schema defined in $defs (or an external file) and inlines it at the point of use. Inside composition keywords, $ref elements behave identically to inline schemas. This enables shared base schemas, reusable union arms, and DRY composition patterns.

// ── $defs + allOf: extend a shared base ─────────────────────────
{
  "$defs": {
    "Timestamps": {
      "type": "object",
      "required": ["createdAt", "updatedAt"],
      "properties": {
        "createdAt": { "type": "string", "format": "date-time" },
        "updatedAt": { "type": "string", "format": "date-time" }
      }
    },
    "SoftDelete": {
      "type": "object",
      "properties": {
        "deletedAt": { "type": ["string", "null"], "format": "date-time" }
      }
    }
  },

  "title": "User",
  "allOf": [
    { "$ref": "#/$defs/Timestamps" },
    { "$ref": "#/$defs/SoftDelete" },
    {
      "type": "object",
      "required": ["id", "email"],
      "properties": {
        "id":    { "type": "string", "format": "uuid" },
        "email": { "type": "string", "format": "email" },
        "role":  { "type": "string", "enum": ["admin", "user", "guest"] }
      }
    }
  ],
  "unevaluatedProperties": false
}

// ── $ref inside anyOf: nullable reference ────────────────────────
{
  "$defs": {
    "Address": {
      "type": "object",
      "required": ["street", "city", "country"],
      "properties": {
        "street":  { "type": "string" },
        "city":    { "type": "string" },
        "country": { "type": "string" }
      }
    }
  },

  "type": "object",
  "properties": {
    "billingAddress": {
      "anyOf": [
        { "$ref": "#/$defs/Address" },
        { "type": "null" }
      ]
    },
    "shippingAddress": {
      "$ref": "#/$defs/Address"
    }
  }
}

// ── $ref inside oneOf: reuse branch schemas ──────────────────────
{
  "$defs": {
    "StripePayment": {
      "type": "object",
      "required": ["processor", "paymentIntentId"],
      "properties": {
        "processor":       { "const": "stripe" },
        "paymentIntentId": { "type": "string", "pattern": "^pi_" }
      }
    },
    "PaypalPayment": {
      "type": "object",
      "required": ["processor", "orderId"],
      "properties": {
        "processor": { "const": "paypal" },
        "orderId":   { "type": "string", "pattern": "^[A-Z0-9]{17}$" }
      }
    }
  },

  "oneOf": [
    { "$ref": "#/$defs/StripePayment" },
    { "$ref": "#/$defs/PaypalPayment" }
  ]
}

// ── Multi-file $ref ──────────────────────────────────────────────
// When using an external schema registry or OpenAPI components file:
{
  "allOf": [
    { "$ref": "https://example.com/schemas/base-event.json" },
    {
      "properties": {
        "payload": { "type": "object" }
      },
      "required": ["payload"]
    }
  ]
}
// Validators must be configured to resolve external URIs
// Ajv: addSchema() or loadSchema() callback
// jsonschema (Python): RefResolver with store parameter

When combining $ref with composition keywords in multi-file schemas, configure your validator to resolve external references. In Ajv (JavaScript), call ajv.addSchema() for each referenced schema or use ajv-pack to bundle all schemas into a single file. In Python's jsonschema library, construct a RefResolver with a store mapping URIs to loaded schemas. Bundled single-file schemas with $defs are simpler to deploy and avoid network dependencies during validation.

Common Mistakes: When to Use oneOf vs anyOf

The most common mistake in JSON Schema composition is using oneOf when schemas can legitimately overlap — causing validation to fail for valid inputs. Conversely, using anyOf when mutual exclusivity is required leaves invalid inputs accepted. Understanding the semantic difference prevents both classes of error.

// ── MISTAKE 1: oneOf with overlapping schemas ───────────────────
// WRONG: "hello" matches BOTH schemas (string AND minLength)
{
  "oneOf": [
    { "type": "string" },
    { "type": "string", "minLength": 3 }
  ]
}
// "hi"     — matches schema 1 only    → oneOf passes ✓
// "hello"  — matches BOTH schemas     → oneOf fails ✗✗✗ (BUG!)

// FIX: Use anyOf if overlap is acceptable
{
  "anyOf": [
    { "type": "string" },
    { "type": "string", "minLength": 3 }
  ]
}
// "hello" — matches both, but anyOf only needs 1 → passes ✓

// ── MISTAKE 2: anyOf instead of oneOf for exclusive variants ────
// WRONG: accepts an object that matches both email AND sms branches
{
  "anyOf": [
    {
      "required": ["email"],
      "properties": { "email": { "type": "string" } }
    },
    {
      "required": ["phone"],
      "properties": { "phone": { "type": "string" } }
    }
  ]
}
// { "email": "a@b.com", "phone": "+1234567890" } → anyOf passes ✓ (matches both)
// But the intent was: only one contact method allowed!

// FIX: Use oneOf for strict mutual exclusivity
{
  "oneOf": [
    {
      "required": ["email"],
      "properties": { "email": { "type": "string" } },
      "additionalProperties": false
    },
    {
      "required": ["phone"],
      "properties": { "phone": { "type": "string" } },
      "additionalProperties": false
    }
  ]
}
// { "email": "a@b.com", "phone": "+1234" } → oneOf fails ✗ (both match without additionalProperties:false trap)
// { "email": "a@b.com" }                  → oneOf passes ✓

// ── MISTAKE 3: oneOf for conditional required fields ─────────────
// WRONG: slow and produces confusing error messages
{
  "oneOf": [
    { "required": ["street", "city"], "properties": { "type": { "const": "physical" } } },
    { "required": ["url"],            "properties": { "type": { "const": "digital" } } }
  ]
}

// FIX: Use if/then/else — cleaner, faster, better errors
{
  "if": { "properties": { "type": { "const": "physical" } }, "required": ["type"] },
  "then": { "required": ["street", "city"] },
  "else": { "required": ["url"] }
}

// ── MISTAKE 4: additionalProperties: false blocking allOf ────────
// WRONG: Base has additionalProperties: false, blocks extended fields
{
  "allOf": [
    {
      "properties": { "id": { "type": "string" } },
      "required": ["id"],
      "additionalProperties": false  // ← blocks "name" from the next branch
    },
    {
      "properties": { "name": { "type": "string" } },
      "required": ["name"]
    }
  ]
}
// { "id": "1", "name": "Alice" } → FAILS because base sees "name" as additional

// FIX: Use unevaluatedProperties: false at the composed level
{
  "allOf": [
    {
      "properties": { "id": { "type": "string" } },
      "required": ["id"]
      // No additionalProperties: false here
    },
    {
      "properties": { "name": { "type": "string" } },
      "required": ["name"]
    }
  ],
  "unevaluatedProperties": false  // ← composition-aware, sees ALL properties
}

A quick decision guide: use allOf to merge constraints (intersection); use anyOf for permissive unions where overlap is acceptable; use oneOf only when you need to enforce that exactly one branch matches and schemas are truly mutually exclusive; use if/then/else for conditional required fields based on a property value — it is cleaner and faster than oneOf for this pattern. When in doubt between oneOf and anyOf, prefer anyOf — it is faster and avoids the "accidentally matches 2 branches" validation failure.

FAQ

What is the difference between allOf, oneOf, and anyOf in JSON Schema?

allOf requires a value to satisfy ALL listed schemas simultaneously — equivalent to TypeScript intersection A & B. If you list 3 schemas in allOf and the value fails even 1 of them, validation fails. oneOf requires EXACTLY ONE schema to match. If the value validates against 2 or more schemas in the oneOf array, validation fails — this mutual exclusivity is the key distinction. anyOf requires AT LEAST ONE schema to match and is the most permissive: if the value validates against any 1 of the listed schemas (or more), validation passes. In practice: use allOf to merge a base schema with additional constraints, use oneOf for strictly exclusive variants (like a payment type that must be either credit OR bank transfer, never both), and use anyOf for permissive unions (like accepting a string or a number).

When should I use oneOf vs anyOf?

Use oneOf when schemas must be mutually exclusive — only exactly 1 schema can match. This is the right choice for discriminated unions where each variant is identified by a distinct type field, and for payment or notification channel variants where the data shapes genuinely do not overlap. Use anyOf when schemas can overlap and you just want at least 1 to match. anyOf is equivalent to TypeScript union A | B. For example, a field that accepts either a positive integer or a non-empty string should use anyOf — a value like "42" does not overlap both, but even if it did, anyOf would still pass. The performance implication: anyOf validators short-circuit after the first match; oneOf validators must check ALL branches and count how many match, which can be up to 3x slower on large union lists. Prefer anyOf unless you genuinely need mutual exclusivity enforcement.

How do I extend a base JSON Schema with allOf?

The standard pattern is to put a $ref to the base schema as the first element of allOf, then add the extension properties as the second element. For example: {"allOf": [{"$ref": "#/$defs/Base"}, {"properties": {"extra": {"type": "string"}}, "required": ["extra"]}]}. This requires the value to satisfy both the Base schema AND have the extra required field. Unlike direct property merging (which is not valid JSON Schema), allOf composition lets validators process each sub-schema independently and combine the results. When extending with allOf, be careful with additionalProperties: false — if the base schema sets it, it will block the extension properties because each sub-schema sees only what it knows about. Use unevaluatedProperties: false instead, which is aware of properties evaluated across all sub-schemas in an allOf.

How do discriminated unions work in JSON Schema?

Discriminated unions use oneOf combined with a const on a shared discriminator field (commonly named type or kind). Each branch in the oneOf array contains a required discriminator field with a specific const value, plus the branch-specific properties. When the value has "type": "cat", only the first branch can match (since the second requires "type": "dog"), so oneOf's mutual-exclusivity constraint is satisfied automatically. Validators like Ajv can short-circuit after finding the matching const, making discriminated unions with oneOf very efficient — typically O(1) branch selection rather than O(N). OpenAPI 3.x also supports a discriminator object for this pattern, which hints code generators but does not affect JSON Schema validation itself.

How does if/then/else work in JSON Schema?

The if/then/else keywords apply conditional validation. If the value validates against the if schema, the then schema is also applied as an additional constraint. If the value does NOT validate against the if schema, the else schema is applied instead. Crucially, the if schema itself never causes validation failure — it is only used to decide which branch (then or else) to apply. For example, you can require zipCode for US addresses and postalCode for non-US addresses. if/then/else is more efficient than oneOf for conditional required fields because validators only check 1 branch instead of all branches, and it produces clearer error messages pinpointing the failed constraint. The else clause is optional — if omitted, there are no extra constraints when the if schema does not match.

How do I model a TypeScript union type in JSON Schema?

TypeScript union A | B maps to JSON Schema anyOf. A simple string | number type becomes {"anyOf": [{"type": "string"}, {"type": "number"}]}. For object union types like Cat | Dog where they share a discriminator field, use oneOf with const on the discriminator for better validation and error messages. TypeScript intersection A & B maps to JSON Schema allOf. The type A & B & {'{ extra: string }'} becomes {"allOf": [{"$ref": "#/$defs/A"}, {"$ref": "#/$defs/B"}, {"properties": {"extra": {"type": "string"}}, "required": ["extra"]}]}. Tools like json-schema-to-typescript and quicktype can convert JSON Schemas with anyOf/oneOf back into TypeScript union types automatically, and zod-to-json-schema converts Zod union schemas to JSON Schema.

Why does my oneOf validation fail when two schemas both match?

oneOf requires EXACTLY one match — if 2 or more schemas in the array both validate the same value, oneOf fails with an error like "must match exactly one schema in oneOf". This is a common mistake when the schemas are not truly mutually exclusive. The most frequent cause: using anyOf-style overlapping schemas in a oneOf. For example, a oneOf with [{"type": "string"}, {"minLength": 3}] will fail for any string with 3+ characters because both schemas match. Fix 1: Switch to anyOf if you do not need mutual exclusivity. Fix 2: Make schemas truly exclusive by adding constraints that prevent overlap — for object unions, add a required const on a discriminator field so only one branch can match for any given value. Fix 3: Use if/then/else instead, which does not have the mutual-exclusivity requirement and avoids this class of error entirely for conditional validation patterns.

How do I use $ref inside composition keywords?

$ref can appear as any element inside allOf, anyOf, or oneOf arrays, or as the if/then/else values. The most common pattern is extending a base schema: {"allOf": [{"$ref": "#/$defs/BaseEvent"}, {"properties": {"payload": {"type": "object"}}, "required": ["payload"]}]}. This requires the value to satisfy both BaseEvent AND have a payload property. $ref resolves to the target schema and inlines its constraints at validation time. In anyOf and oneOf, $ref elements work identically to inline schemas. You can mix $ref and inline schemas freely: {"anyOf": [{"$ref": "#/$defs/UserInput"}, {"type": "null"}]} creates a nullable user input. When using bundled schemas with $defs, ensure your validator has the $defs declared at the root or an accessible ancestor so $ref resolution succeeds without network requests.

Validate JSON Schema instantly

Paste your JSON and schema into Jsonic's validator to check allOf, oneOf, anyOf, and if/then/else rules in real time.

Open JSON Schema Validator

Further reading and primary sources