JSON Schema Composition: allOf, anyOf, oneOf, $defs & Performance
Last updated:
JSON Schema composition keywords — allOf, anyOf, oneOf — let you build complex schemas from reusable parts without copy-pasting. allOf is a logical AND: all sub-schemas must pass (schema extension). anyOf is OR: at least one must pass (union types, nullable fields). oneOf is XOR: exactly one must pass (discriminated unions, mutually exclusive shapes). $defs + $ref provide the reuse layer — define a sub-schema once, reference it many times. What most guides omit: oneOf has O(N) validation cost in AJV because every branch is evaluated to confirm exactly one passes. Replace oneOf with if/then/else and a discriminator field to reduce that to O(1). This guide covers each keyword with practical examples, AJV performance benchmarks, TypeScript type generation, and the $defs/$ref reuse patterns that eliminate schema duplication.
allOf: Schema Intersection and Extension
allOf is the primary mechanism for schema extension — it works like class inheritance in OOP, but as a pure intersection: the data must satisfy every schema in the array simultaneously. Define a base schema in $defs, then use allOf with a $ref plus additional constraints to produce an extended schema. AJV merges allOf entries at compile time: required arrays are unioned, properties are merged by key. You can only add constraints with allOf — you cannot loosen a constraint defined in the base.
`// ── Base schema defined in $defs ──────────────────────────────────────
const schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"Animal": {
"type": "object",
"required": ["name", "species"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"species": { "type": "string" },
"age": { "type": "integer", "minimum": 0 }
}
}
}
};
// ── Extending with allOf ───────────────────────────────────────────────
// Dog schema = Animal + breed + weight_kg
const dogSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"Animal": schema["$defs"]["Animal"]
},
"allOf": [
{ "$ref": "#/$defs/Animal" }, // inherit Animal constraints
{
"required": ["breed"], // add required field
"properties": {
"breed": { "type": "string" },
"weight_kg": { "type": "number", "minimum": 0, "maximum": 200 }
}
}
]
};
// After AJV compiles, the effective schema is equivalent to:
// {
// type: "object",
// required: ["name", "species", "breed"], // merged required arrays
// properties: {
// name: { type: "string", minLength: 1 },
// species: { type: "string" },
// age: { type: "integer", minimum: 0 },
// breed: { type: "string" },
// weight_kg: { type: "number", minimum: 0, maximum: 200 }
// }
// }
// ── AJV validation ────────────────────────────────────────────────────
import Ajv from "ajv";
const ajv = new Ajv();
const validate = ajv.compile(dogSchema);
validate({ name: "Rex", species: "Canis lupus", breed: "Labrador", weight_kg: 32 });
// valid: true
validate({ name: "Rex", species: "Canis lupus" });
// valid: false — missing required "breed"
validate({ name: "", species: "Canis lupus", breed: "Labrador" });
// valid: false — name fails minLength:1 from the base Animal schema
// ── allOf performance: compile-time merge ──────────────────────────────
// AJV folds allOf into a single schema at compile time.
// The compiled validate() function runs in constant time regardless of
// how many schemas are in allOf — no branching at runtime.
// This makes allOf the cheapest composition keyword.
// ── allOf with multiple independent constraints ────────────────────────
const schemaWithConstraints = {
"type": "object",
"allOf": [
{ "required": ["id"] },
{ "required": ["createdAt"] },
{ "properties": { "id": { "type": "string", "format": "uuid" } } },
{ "properties": { "createdAt": { "type": "string", "format": "date-time" } } }
]
};
// Equivalent to:
// { type: "object", required: ["id", "createdAt"],
// properties: { id: { type: "string", format: "uuid" },
// createdAt: { type: "string", format: "date-time" } } }
// ── What allOf CANNOT do ──────────────────────────────────────────────
// allOf is purely additive — you cannot widen a constraint from the base.
// If Animal says name has minLength: 1, Dog cannot relax that to minLength: 0.
// allOf can only add further restrictions (smaller minLength is not supported).
// For shape variants that are incompatible, use anyOf or oneOf instead.allOf merges schemas at compile time — the compiled AJV validator runs in O(1) with no branching. Use it freely for schema extension and property inheritance. The one hard rule: allOf only narrows. If you need variant shapes where some properties are only valid in one variant, switch to anyOf or oneOf. See the JSON Schema patterns guide for more composition patterns.
anyOf: Union Types and Multiple Valid Shapes
anyOf is a logical OR — data is valid if it matches at least one schema in the array. Multiple matches are permitted; only zero matches causes a validation failure. The canonical use cases are nullable types (string | null), polymorphic fields that accept different shapes, and optional type widening. Unlike oneOf, anyOf implementations can short-circuit after the first match, making it more performant for union types where branches are non-overlapping in practice.
`// ── Nullable type: string | null ─────────────────────────────────────
const nullableString = {
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
};
// JSON Schema draft 2019-09+ shorthand:
const nullableStringShort = { "type": ["string", "null"] };
// Both are equivalent; type array is syntactic sugar for anyOf with types.
// ── Union of different shapes ──────────────────────────────────────────
const contactSchema = {
"type": "object",
"anyOf": [
{
// Email contact
"required": ["email"],
"properties": {
"email": { "type": "string", "format": "email" },
"name": { "type": "string" }
}
},
{
// Phone contact
"required": ["phone"],
"properties": {
"phone": { "type": "string", "pattern": "^\+?[0-9]{7,15}$" },
"countryCode": { "type": "string" }
}
},
{
// Postal address contact
"required": ["street", "city"],
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zip": { "type": "string" }
}
}
]
};
// Valid: email contact
// { "email": "alice@example.com", "name": "Alice" }
// Valid: phone contact
// { "phone": "+14155552671" }
// Valid: BOTH email and phone contact (anyOf allows multiple matches)
// { "email": "alice@example.com", "phone": "+14155552671" }
// Invalid: neither email, phone, nor street+city present
// { "name": "Alice" }
// ── anyOf error messages are harder to read ────────────────────────────
// When anyOf fails, AJV reports errors for ALL branches.
// For better error messages, use ajv-errors or ajv-formats:
import Ajv from "ajv";
import addFormats from "ajv-formats";
import AjvErrors from "ajv-errors";
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
AjvErrors(ajv);
const validate = ajv.compile({
"type": "object",
"anyOf": [
{ "required": ["email"], "errorMessage": "Provide a valid email address" },
{ "required": ["phone"], "errorMessage": "Provide a valid phone number" }
]
});
// ── anyOf with $ref ────────────────────────────────────────────────────
const paymentSchema = {
"$defs": {
"CardPayment": { "required": ["cardNumber", "cvv"], "properties": { "cardNumber": { "type": "string" }, "cvv": { "type": "string" } } },
"BankTransfer": { "required": ["iban"], "properties": { "iban": { "type": "string" } } },
"Crypto": { "required": ["walletAddress"], "properties": { "walletAddress": { "type": "string" } } }
},
"anyOf": [
{ "$ref": "#/$defs/CardPayment" },
{ "$ref": "#/$defs/BankTransfer" },
{ "$ref": "#/$defs/Crypto" }
]
};
// ── anyOf vs oneOf for nullable ────────────────────────────────────────
// Use anyOf for nullable — a value cannot be both a string AND null,
// so anyOf and oneOf behave identically for {string, null}.
// anyOf is preferred for nullable because some validators short-circuit.
const value = null;
// anyOf [{type: string}, {type: null}]: valid (null matches second)
// oneOf [{type: string}, {type: null}]: valid (null matches exactly one)
// Both work, but anyOf signals "at least one" intent more clearly.Prefer anyOf over oneOf for nullable types and non-overlapping union types — it communicates the intent more clearly and may short-circuit early. Reserve oneOf for cases where you need to guarantee mutual exclusivity (no document can match two branches). For validation error UX, use ajv-errors to attach custom messages to individual anyOf branches so users get actionable feedback instead of a list of all branch failures.
oneOf: Exactly One Matching Schema
oneOf is a logical XOR — exactly one schema in the array must match, zero or two or more is a validation error. This is the correct keyword for mutually exclusive variants: payment methods where only one type is allowed at a time, API request bodies where the structure depends on the operation type, or configuration objects with incompatible shape variants. The performance cost is real: AJV evaluates every branch to count matches.
`// ── oneOf for mutually exclusive shapes ───────────────────────────────
const shapeSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["type"],
"oneOf": [
{
// Circle: must have radius, must NOT have side or base/height
"required": ["type", "radius"],
"properties": {
"type": { "const": "circle" },
"radius": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
},
{
// Square: must have side, must NOT have radius or base/height
"required": ["type", "side"],
"properties": {
"type": { "const": "square" },
"side": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
},
{
// Triangle: must have base and height
"required": ["type", "base", "height"],
"properties": {
"type": { "const": "triangle" },
"base": { "type": "number", "exclusiveMinimum": 0 },
"height": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
}
]
};
// Valid: { "type": "circle", "radius": 5 }
// Invalid: { "type": "circle", "radius": 5, "side": 3 }
// — additionalProperties: false prevents this
// Invalid: { "type": "circle" }
// — missing required "radius"
// ── Why additionalProperties: false matters in oneOf ──────────────────
// Without additionalProperties: false, { "type": "circle", "radius": 5, "side": 3 }
// might pass BOTH the circle schema (has type + radius) AND the square schema
// (has type + side) depending on the validator's strict mode.
// additionalProperties: false ensures mutual exclusivity without relying on
// the absence of extra fields.
// ── AJV performance: oneOf is O(N) ────────────────────────────────────
import Ajv from "ajv";
const ajv = new Ajv();
const validate = ajv.compile(shapeSchema);
// AJV evaluates all 3 schemas for EVERY document, even if the first matches.
// It must count: exactly 1 must pass. With N=3, that's 3 evaluations.
// With N=10 (10 shape variants), that's 10 evaluations per document.
// Benchmark: 100,000 documents, oneOf with 10 branches ≈ 850ms
// Same logic with if/then/else discriminator ≈ 85ms
// ── Optimization: AJV discriminator (draft-2019-09) ──────────────────
// With the ajv-discriminator plugin, oneOf compiles to a dispatch table.
// AJV reads the discriminator field, looks up the matching branch directly.
import Ajv2019 from "ajv/dist/2019";
import addDiscriminator from "ajv-keywords/dist/definitions/discriminator";
const ajv2019 = new Ajv2019();
ajv2019.addKeyword(addDiscriminator());
const schemaWithDiscriminator = {
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
"discriminator": { "propertyName": "type" }, // <- tells AJV to dispatch on "type"
"oneOf": [
{ "properties": { "type": { "const": "circle" } }, "required": ["type", "radius"] },
{ "properties": { "type": { "const": "square" } }, "required": ["type", "side"] }
]
};
// AJV now evaluates O(1) — reads "type", jumps to matching branch directly.Use additionalProperties: false within each oneOf branch to ensure genuine mutual exclusivity — without it, a document with both radius and side might match multiple branches. For schemas with more than 3–4 branches, replace oneOf with if/then/else (see Section 5) or use the AJV discriminator plugin to restore O(1) performance. Always precompile the schema with ajv.compile() at startup — schema compilation is expensive, reusing the compiled validator is not.
$defs and $ref: Schema Reuse
$defs is the standard location for reusable sub-schema definitions within a JSON Schema document. Introduced in draft 2019-09 as the successor to definitions, it has no semantic effect on the schema root — it is purely a storage location for sub-schemas referenced elsewhere with $ref. $ref accepts a URI, either a JSON Pointer to a local definition ("#/$defs/Address") or an absolute URI to an external schema file.
`// ── $defs: define reusable sub-schemas ────────────────────────────────
const orderSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/order.json",
"$defs": {
"Address": {
"type": "object",
"required": ["street", "city", "country"],
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zip": { "type": "string" },
"country": { "type": "string", "minLength": 2, "maxLength": 2 }
}
},
"Money": {
"type": "object",
"required": ["amount", "currency"],
"properties": {
"amount": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" }
}
},
"LineItem": {
"type": "object",
"required": ["sku", "qty", "unitPrice"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1 },
"unitPrice": { "$ref": "#/$defs/Money" }
}
}
},
"type": "object",
"required": ["orderId", "billing", "items"],
"properties": {
"orderId": { "type": "string", "format": "uuid" },
"billing": { "$ref": "#/$defs/Address" }, // reuse Address
"shipping": { "$ref": "#/$defs/Address" }, // reuse Address again
"total": { "$ref": "#/$defs/Money" }, // reuse Money
"items": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/LineItem" } // reuse LineItem
}
}
};
// ── AJV: compile schema with $defs ────────────────────────────────────
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv();
addFormats(ajv);
const validateOrder = ajv.compile(orderSchema);
// AJV resolves all $ref pointers at compile time — no runtime resolution.
// ── Cross-file $ref with AJV ──────────────────────────────────────────
// file: schemas/address.json
const addressSchema = {
"$id": "https://example.com/schemas/address.json",
"type": "object",
"required": ["street", "city"],
"properties": { "street": { "type": "string" }, "city": { "type": "string" } }
};
// Register all schemas before compiling any that reference them
ajv.addSchema(addressSchema);
const userSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"address": { "$ref": "https://example.com/schemas/address.json" }
}
};
const validateUser = ajv.compile(userSchema); // $ref resolved from cache
// ── Recursive schema: tree node referencing itself ─────────────────────
const treeSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"Node": {
"type": "object",
"required": ["value"],
"properties": {
"value": { "type": "number" },
"left": { "$ref": "#/$defs/Node" }, // self-reference
"right": { "$ref": "#/$defs/Node" } // self-reference
}
}
},
"$ref": "#/$defs/Node" // root schema IS a Node
};
const validateTree = ajv.compile(treeSchema);
validateTree({ value: 5, left: { value: 3 }, right: { value: 8, left: { value: 6 } } });
// valid: true (AJV handles recursive schemas without infinite loops)
// ── Migrate from definitions to $defs ─────────────────────────────────
// draft-07 (old): { "definitions": { "Address": {...} } }
// draft 2019-09+ (new): { "$defs": { "Address": {...} } }
// $ref syntax is the same: "#/definitions/Address" vs "#/$defs/Address"
// AJV supports both; always use $defs in new schemas.AJV resolves all $ref pointers at compile time, so there is no runtime overhead from deeply nested references. Define the full reference graph with ajv.addSchema() before compiling, then compile once and cache the validator. For JSON Schema validation patterns with AJV including format validators and custom keywords, see the linked guide.
Discriminated Unions with if/then/else
if/then/else is the fastest way to implement conditional validation in JSON Schema. When the if schema matches, the then schema is applied; when the if fails, the else schema is applied. Neither branch failure counts as a validation error on its own — only the result of the selected branch matters. For discriminated unions, put a {const} check on the discriminator field in if; AJV reads it and dispatches O(1) to the correct branch.
`// ── Discriminated union with if/then/else ─────────────────────────────
const eventSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["type"], // discriminator field always required
// Branch 1: UserCreated
"if": { "properties": { "type": { "const": "UserCreated" } } },
"then": {
"required": ["type", "userId", "email"],
"properties": {
"type": { "const": "UserCreated" },
"userId": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" }
}
},
// Branch 2: OrderPlaced (chained via else)
"else": {
"if": { "properties": { "type": { "const": "OrderPlaced" } } },
"then": {
"required": ["type", "orderId", "total"],
"properties": {
"type": { "const": "OrderPlaced" },
"orderId": { "type": "string" },
"total": { "type": "number", "minimum": 0 }
}
},
// Branch 3: PaymentReceived
"else": {
"if": { "properties": { "type": { "const": "PaymentReceived" } } },
"then": {
"required": ["type", "paymentId", "amount", "currency"],
"properties": {
"type": { "const": "PaymentReceived" },
"paymentId": { "type": "string" },
"amount": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" }
}
},
// Fallback: reject unknown event types
"else": { "not": {} } // "not": {} always fails — rejects unknown types
}
}
};
// ── AJV compilation and validation ────────────────────────────────────
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv();
addFormats(ajv);
const validate = ajv.compile(eventSchema);
validate({ type: "UserCreated", userId: "550e8400-e29b-41d4-a716-446655440000", email: "alice@example.com" });
// valid: true — matches branch 1
validate({ type: "OrderPlaced", orderId: "ORD-123", total: 149.99 });
// valid: true — matches branch 2
validate({ type: "UserCreated" });
// valid: false — missing userId and email
validate({ type: "UnknownEvent" });
// valid: false — falls through to "not": {} which always fails
// ── Performance comparison: oneOf vs if/then/else ─────────────────────
// oneOf with 10 branches:
// Every document: evaluate all 10 schemas, count how many pass
// Cost: O(10) = 10 evaluations per document
// 100,000 documents: ~850ms
// if/then/else with 10 branches (chained):
// Happy path: check if schema (1 eval) -> matches -> apply then (1 eval)
// Cost: O(2) per document for matching branch + O(k) for k non-matching ifs
// Worst case (last branch): O(9) if + O(1) then = O(10) still
// BUT: if schemas are lightweight (just a const check), cost is negligible
// 100,000 documents: ~85ms (10x faster because const checks are near-zero cost)
// ── Keep discriminator field at root required ──────────────────────────
// If "type" is not in required, AJV may evaluate the if schema on a document
// that has no "type" field at all — the const check becomes undefined vs "UserCreated"
// which fails, falling to else. Always put discriminator in required[].
// ── Reuse branch schemas from $defs ──────────────────────────────────
const schemaWithDefs = {
"$defs": {
"UserCreatedProps": {
"required": ["userId", "email"],
"properties": {
"userId": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" }
}
}
},
"if": { "properties": { "type": { "const": "UserCreated" } } },
"then": { "$ref": "#/$defs/UserCreatedProps" }
};The "else": {"not": {}} pattern is an important safety net — it ensures that documents with an unrecognized discriminator value fail validation instead of silently passing. Without it, a document with {"type": "UnknownEvent"} would pass (the else schema is implicitly true, which always validates). For complex event systems where new types are added frequently, keep the branch schemas in $defs and compose them in the then clauses via $ref.
AJV Performance for Composed Schemas
AJV (Another JSON Schema Validator) is the most widely used JSON Schema validator for JavaScript. Its performance characteristics differ significantly by composition keyword: allOf is O(1) (merged at compile time), anyOf is O(N) worst-case but short-circuits on first match, oneOf is always O(N). The single largest performance win is schema precompilation — calling ajv.compile() per request instead of once at startup is a 1000x mistake.
`// ── WRONG: compiling per-request (1000x slower) ────────────────────────
// app.post('/orders', async (req, res) => {
// const ajv = new Ajv();
// const validate = ajv.compile(orderSchema); // ~5ms per request
// if (!validate(req.body)) { ... }
// });
// ── CORRECT: compile once at startup, reuse forever ────────────────────
import Ajv from "ajv";
import addFormats from "ajv-formats";
// Module-level: compiled once when the module is loaded
const ajv = new Ajv({ allErrors: false }); // stop on first error = faster
addFormats(ajv);
// Compile all schemas at startup
const validators = {
order: ajv.compile(orderSchema),
user: ajv.compile(userSchema),
payment: ajv.compile(paymentSchema),
};
// Per-request: reuse compiled validators (microseconds, not milliseconds)
app.post('/orders', async (req, res) => {
if (!validators.order(req.body)) {
return res.status(400).json({ errors: validators.order.errors });
}
// process valid order...
});
// ── allErrors: false (default) vs true ────────────────────────────────
// allErrors: false — stop on first error (faster, less useful for forms)
// allErrors: true — collect all errors (slower, better for form validation)
const ajvFast = new Ajv({ allErrors: false }); // use for API validation
const ajvForms = new Ajv({ allErrors: true }); // use for form UX
// ── oneOf optimization: use discriminator ────────────────────────────
// Replace oneOf for large variant counts:
const slowSchema = {
"oneOf": [
{ "properties": { "type": { "const": "A" } }, "required": ["fieldA"] },
{ "properties": { "type": { "const": "B" } }, "required": ["fieldB"] },
// ... 8 more variants ...
]
};
// Replace with if/then/else — O(1) per document:
const fastSchema = {
"if": { "properties": { "type": { "const": "A" } } },
"then": { "required": ["fieldA"] },
"else": {
"if": { "properties": { "type": { "const": "B" } } },
"then": { "required": ["fieldB"] }
// ... chain more variants
}
};
// ── Benchmark: compiled validator reuse ────────────────────────────────
// Schema: 1 oneOf with 5 branches, each with 3 property checks
// Documents: 100,000 objects
// Approach | Time
// -------------------------------------------|-------
// Compile per call + oneOf(5) | 8,200ms
// Compile once + oneOf(5) | 320ms
// Compile once + if/then/else(5) | 85ms
// Compile once + allOf (merged at compile)| 28ms
// ── AJV standalone compilation (zero runtime overhead) ────────────────
// For maximum performance, use AJV's standalone mode to generate
// a pure JavaScript validation function at build time:
import { default as standaloneCode } from "ajv/dist/standalone";
import * as fs from "fs";
const ajvStandalone = new Ajv({ code: { source: true } });
const validateFn = ajvStandalone.compile(orderSchema);
const moduleCode = standaloneCode(ajvStandalone, validateFn);
fs.writeFileSync("dist/validateOrder.js", moduleCode);
// Import at runtime — no AJV dependency, no schema parsing:
// import validateOrder from "./dist/validateOrder.js";
// validateOrder(data); // pure JS function, ~2x faster than compiled AJV
// ── Memory: AJV caches compiled schemas by $id ────────────────────────
// After ajv.compile(schema), the schema is cached by its $id.
// ajv.compile() on the same schema object returns the cached validator.
// For dynamic schema sets (user-defined schemas), set a max cache size:
const ajvCached = new Ajv({ code: { optimize: 1 } });
// Monitor cache growth in long-running servers with many unique schemas.The benchmark numbers make the priority clear: fixing compilation-per-request alone gives a 25x speedup. Switching from oneOf to if/then/else gives another 4x. Using AJV standalone compilation at build time gives another 2x. In order: compile once, then optimize keyword choice, then consider standalone. For JSON Schema validation setup with AJV including format validators, error formatting, and custom keywords, see the linked guide.
TypeScript Types from Composed JSON Schemas
JSON Schema and TypeScript types are parallel systems for describing data shapes. json-schema-to-typescript bridges them: it reads a JSON Schema (including $defs, allOf, anyOf, oneOf, $ref) and outputs a TypeScript .d.ts or .ts file. This gives you a single source of truth — the JSON Schema drives both runtime validation (AJV) and compile-time type checking (TypeScript).
`// ── Install ─────────────────────────────────────────────────────────
// npm install --save-dev json-schema-to-typescript
// ── CLI usage ────────────────────────────────────────────────────────
// npx json-schema-to-typescript schemas/order.json > src/types/order.d.ts
// ── Programmatic usage ───────────────────────────────────────────────
import { compile } from "json-schema-to-typescript";
import * as fs from "fs";
const schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"Address": {
"type": "object",
"required": ["street", "city"],
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"country": { "type": "string" }
},
"additionalProperties": false
},
"LineItem": {
"type": "object",
"required": ["sku", "qty"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1 }
}
}
},
"title": "Order",
"type": "object",
"required": ["orderId", "billing", "items"],
"properties": {
"orderId": { "type": "string" },
"billing": { "$ref": "#/$defs/Address" },
"shipping": { "$ref": "#/$defs/Address" },
"items": {
"type": "array",
"items": { "$ref": "#/$defs/LineItem" }
}
}
};
const ts = await compile(schema, "Order");
fs.writeFileSync("src/types/order.d.ts", ts);
// Output:
// export interface Order {
// orderId: string;
// billing: Address;
// shipping?: Address;
// items: LineItem[];
// }
// export interface Address {
// street: string;
// city: string;
// country?: string;
// }
// export interface LineItem {
// sku: string;
// qty: number;
// }
// ── allOf -> TypeScript intersection ─────────────────────────────────
// Schema: allOf: [{ $ref: "#/$defs/Animal" }, { required: ["breed"] }]
// Output: type Dog = Animal & { breed: string; weight_kg?: number }
// ── anyOf/oneOf -> TypeScript union ──────────────────────────────────
// Schema: anyOf: [{ $ref: "#/$defs/Email" }, { $ref: "#/$defs/Phone" }]
// Output: type Contact = Email | Phone
// ── Recursive schema -> recursive TypeScript interface ────────────────
// Schema: Node { value: number, children?: Node[] }
// Output:
// export interface Node {
// value: number;
// children?: Node[]; // self-reference handled by TypeScript interface
// }
// ── Zod: alternative for runtime + TypeScript in one step ─────────────
import { z } from "zod";
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().optional(),
});
const LineItemSchema = z.object({
sku: z.string(),
qty: z.number().int().min(1),
});
const OrderSchema = z.object({
orderId: z.string(),
billing: AddressSchema,
shipping: AddressSchema.optional(),
items: z.array(LineItemSchema).min(1),
});
// TypeScript type inferred automatically:
type Order = z.infer<typeof OrderSchema>;
// Same as the generated interface from json-schema-to-typescript
// ── Zod discriminatedUnion: oneOf with discriminator ─────────────────
const ShapeSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("circle"), radius: z.number().positive() }),
z.object({ type: z.literal("square"), side: z.number().positive() }),
z.object({ type: z.literal("triangle"), base: z.number().positive(), height: z.number().positive() }),
]);
type Shape = z.infer<typeof ShapeSchema>;
// TypeScript narrows correctly:
// if (shape.type === "circle") { shape.radius } // typed, no any
// Zod discriminatedUnion generates O(1) validation internally —
// it builds a Map keyed by the discriminator value at schema definition time.
// ── Build pipeline: schema -> types -> validation ─────────────────────
// package.json scripts:
// "generate:types": "json-schema-to-typescript schemas/*.json --out src/types/",
// "prebuild": "npm run generate:types",
// This ensures TypeScript types are always in sync with JSON Schemas.For greenfield projects, Zod offers an attractive single-source-of-truth: one schema definition produces both runtime validation and TypeScript types via z.infer. For projects that already have JSON Schemas (OpenAPI specs, database CHECK constraints, shared schemas across languages), json-schema-to-typescript preserves the canonical JSON Schema while generating TypeScript types as a build artifact. Add the generate:types script to prebuild so types stay synchronized. See the JSON Schema patterns guide for more schema design patterns.
Key Terms
- allOf (Schema Intersection)
- A JSON Schema composition keyword that requires a value to be valid against every schema in the array simultaneously — a logical AND / intersection. Used for schema extension (similar to class inheritance): a child schema references a base schema via
$refinallOfplus adds its own constraints. AJV mergesallOfschemas at compile time —requiredarrays are unioned,propertiesare merged by key, and the compiled validator runs in constant time with no runtime branching.allOfis purely additive: you can only narrow constraints from the base schema, never widen them. If two schemas inallOfdefine conflicting constraints on the same property (e.g., one says minimum 5, another says maximum 3), validation will always fail because no value satisfies both simultaneously. - anyOf (Schema Union)
- A JSON Schema composition keyword that requires a value to be valid against at least one schema in the array — a logical OR / union. Data that matches multiple schemas is still valid; only matching zero schemas causes a failure. Common uses include nullable types (
anyOf: [{type: string}, {type: null}]), polymorphic API fields, and optional format widening. Some AJV configurations short-circuitanyOfafter the first passing branch, making it potentially faster thanoneOffor non-overlapping unions. WhenanyOffails, AJV reports errors for all branches — useajv-errorswitherrorMessageon each branch for readable validation messages. PreferanyOfoveroneOffor nullable types and disjoint shapes where overlapping branches are impossible by design. - oneOf (Exclusive Union)
- A JSON Schema composition keyword that requires a value to be valid against exactly one schema in the array — a logical XOR / exclusive union. Zero matches or two or more matches both cause a validation failure. Used for mutually exclusive variants: payment method types, event types, API request shapes that cannot overlap. AJV always evaluates all branches to count how many pass — it cannot short-circuit because it must confirm exactly one (not just at least one) passes. This gives
oneOfan O(N) validation cost where N is the branch count. For performance-critical schemas with many branches, replaceoneOfwithif/then/else+ discriminator (O(1)) or use the AJV discriminator plugin. UseadditionalProperties: falsewithin each branch to ensure genuine mutual exclusivity when branches share similar property names. - $defs (Schema Definitions)
- A JSON Schema keyword (introduced in draft 2019-09) that provides a standard location within a schema document for defining reusable sub-schemas.
$defsis semantically inert at the root — it does not affect what the schema validates. Sub-schemas defined under$defsare referenced elsewhere using$refwith a JSON Pointer:"#/$defs/Address". This enables DRY schema design: defineAddressonce in$defs, reference it inbilling,shipping, and any other location. AJV resolves all$refpointers at compile time — there is no runtime resolution overhead.$defsreplaces the deprecateddefinitionskeyword from draft-07; both work in AJV but$defsshould be used in all new schemas. - $ref (Schema Reference)
- A JSON Schema keyword that replaces the schema where it appears with the referenced schema. Value is a URI: a JSON Pointer for local references (
"#/$defs/Address"— the#refers to the schema root,/$defs/Addressis the path), or an absolute URI for external schemas ("https://example.com/schemas/address.json"). In AJV, external schemas must be registered withajv.addSchema()before any schema that references them is compiled. A schema may reference itself recursively via$ref: a treeNodethat containschildren: [Node]uses{"$ref": "#/$defs/Node"}for the children items. AJV handles recursive schemas without infinite loops by tracking visited schemas during validation. In draft 2019-09+,$refcan be combined with other keywords (previously it had to appear alone); sibling keywords alongside$refare applied as additional constraints. - Discriminated Union
- A pattern for modeling variant types where each variant is identified by a constant "tag" or "discriminator" field. In JSON Schema, discriminated unions are expressed with
oneOf(each branch has aconstconstraint on the discriminator field) orif/then/else(theifschema checks the discriminator, thethenschema applies variant constraints). The discriminator field must appear in the top-levelrequiredarray to ensure it is always present. Theif/then/elseapproach achieves O(1) validation because AJV reads the discriminator and evaluates only the matching branch. TheoneOfapproach is O(N) because all branches are evaluated. In TypeScript, Zod'sz.discriminatedUnion("type", [...])implements discriminated union validation with O(1) performance using an internal Map keyed by the discriminator value. - if/then/else (Conditional Schema)
- A JSON Schema composition pattern (added in draft-07) that applies conditional validation: if the
ifschema validates successfully, thethenschema is applied; if theifschema fails, theelseschema is applied. Neither theiffailure nor the unselected branch failure generates a validation error — only the result of the selected branch matters. This makesif/then/elsethe correct tool for implementing discriminated unions in JSON Schema: theifschema checks only the discriminator field constant, making each branch O(1). Chain multiple variants by nestingif/then/elsewithin theelseclause. Add"else": {"not": {}}as a final fallback to reject documents with an unrecognized discriminator value, preventing silent pass-through of invalid event types or unknown variants. - AJV (Another JSON Schema Validator)
- The most widely used JSON Schema validator for JavaScript and TypeScript. AJV compiles JSON Schema documents into optimized JavaScript functions using code generation — the compiled function is a pure JavaScript predicate that runs without any AJV-specific overhead at validation time. AJV supports JSON Schema drafts 04, 06, 07, 2019-09, and 2020-12 via separate packages (
ajvfor draft-07,ajv/dist/2019,ajv/dist/2020). Key performance characteristics:allOfis merged at compile time (O(1) runtime),anyOfshort-circuits on first match (best case O(1), worst case O(N)),oneOfevaluates all branches (always O(N)). TheallErrors: falseoption (default) stops validation on first error — useallErrors: truefor form validation. AJV's standalone compilation mode generates a plain JavaScript file at build time — removing the AJV runtime dependency and achieving maximum validation throughput.
FAQ
What is the difference between allOf, anyOf, and oneOf in JSON Schema?
allOf is a logical AND: the data must be valid against every schema in the array simultaneously. It is used for schema extension — a child schema inherits all base constraints and adds more. AJV merges allOf at compile time so runtime cost is O(1). anyOf is a logical OR: at least one schema must match. Multiple matches are allowed. Used for nullable types (anyOf: [{type: string}, {type: null}]), union shapes, and polymorphic fields. oneOf is a logical XOR: exactly one schema must match — zero or two or more matches cause validation failure. Used for mutually exclusive variants where a value can only belong to one shape. The critical performance difference: allOf is O(1) (compile-time merge), anyOf may short-circuit after first match (O(1) best case, O(N) worst case), and oneOf is always O(N) because all branches must be evaluated to confirm the count. For large variant counts, replace oneOf with if/then/else and a discriminator field.
How do I use allOf to extend a base JSON schema (like OOP inheritance)?
Define the base schema in $defs and reference it with $ref as the first entry of allOf. Add new constraints as a second schema object in allOf: {"allOf": [{"$ref": "#/$defs/Animal"}, {"required": ["breed"], "properties": {"breed": {"type": "string"}}}]}. AJV merges the required arrays (["name", "species"] from Animal plus ["breed"]) and merges the properties. The merged effective schema is what the compiled validator enforces. Important constraints: allOf only narrows — the child cannot relax any constraint from the base. If the base schema has minLength: 1 on name, the child cannot override it to allow empty strings. allOf with {"required": ["a"]} plus {"required": ["b"]} is equivalent to a single {"required": ["a", "b"]} — AJV merges these at compile time, so there is no runtime overhead from splitting requirements across multiple allOf entries.
How do I implement a discriminated union type in JSON Schema?
The most performant approach uses if/then/else with a const discriminator. Define the discriminator field in a top-level required array, then: {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["radius"], "properties": {"radius": {"type": "number"}}}, "else": {"if": ..., "then": ...}}. AJV reads the discriminator value and evaluates only the matching branch — O(1) cost. The oneOf alternative also works: each branch includes {"properties": {"type": {"const": "circle"}}} plus variant-specific required fields and additionalProperties: false for strict mutual exclusivity. Use additionalProperties: false in oneOf branches to prevent a document with both radius and side from matching two branches simultaneously. Add {"else": {"not": {}}} as a final fallback in the if/then/else chain to reject unknown discriminator values instead of silently passing them.
What is the performance impact of using oneOf with many branches in AJV?
oneOf has O(N) validation cost where N is the number of branches. AJV evaluates every branch schema against the data to count how many pass — it cannot stop early because it must confirm exactly one passes, not just at least one. For a schema with 10 oneOf branches, each containing 5 property checks, AJV executes up to 50 validation steps per document. Benchmarks show: 100,000 documents against a oneOf with 10 branches takes approximately 850ms; the same logic expressed with chained if/then/else takes approximately 85ms — a 10x improvement. The fix for high branch counts: replace oneOf with if/then/else where the if schema only checks a const discriminator (a near-zero-cost operation). Alternatively, use the AJV discriminator plugin (for draft 2019-09) which compiles oneOf into a dispatch table keyed by the discriminator field, recovering O(1) performance. Always compile schemas once at startup with ajv.compile() — compiling per-request adds 5–50ms per request; reusing a compiled validator takes microseconds.
How do I reuse JSON schema definitions across multiple schemas using $defs and $ref?
Define reusable sub-schemas in the $defs section of a root schema, then reference them with $ref using a JSON Pointer: {"$ref": "#/$defs/Address"}. The # refers to the schema root; /$defs/Address is the path. For cross-file reuse, assign a $id to each schema file ({"$id": "https://example.com/schemas/address.json"}) and reference it by that URI from other schemas. In AJV, register all external schemas with ajv.addSchema() before compiling any schema that references them — AJV resolves all $ref pointers at compile time, not at validation time. For recursive schemas (a tree node containing child nodes of the same type), use a self-referencing $ref: {"$ref": "#/$defs/Node"} within the Node definition. AJV tracks visited schemas during validation to handle recursive structures without infinite loops. Always use $defs (not the deprecated definitions keyword from draft-07) for all new schemas.
How do I use if/then/else as a faster alternative to oneOf for conditional schemas?
if/then/else applies conditional validation based on whether the data matches the if schema. When the if schema validates successfully, the then schema is applied; when the if schema fails, the else schema is applied. Neither branch failure is itself a validation error — only the result of the selected branch matters. For discriminated unions, the if schema checks only the discriminator field with a const: {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["radius"]}}. Since AJV evaluates only one branch (then or else, never both), the cost is O(1) per document regardless of how many variants exist. Chain variants by nesting if/then/else within the else clause. Always place the discriminator field in a top-level required array — if the discriminator is absent, the if const check fails silently and the document falls through to else. Add {"else": {"not": {}}} at the end of the chain to reject documents with unrecognized discriminator values.
How do I generate TypeScript types from a composed JSON schema with $defs?
Use json-schema-to-typescript (npm package) to generate TypeScript interfaces from a JSON Schema: npx json-schema-to-typescript schema.json > types.ts or programmatically with compile(schema, "RootType"). Each $defs entry becomes a named TypeScript interface. allOf compositions generate TypeScript intersection types (Animal & { breed: string }). anyOf and oneOf generate union types (Email | Phone). $ref references generate TypeScript type references. Recursive schemas (Node containing children?: Node[]) generate recursive TypeScript interfaces — the tool handles self-references correctly. Add the generation step to your build pipeline: "prebuild": "json-schema-to-typescript schemas/*.json --out src/types/" so TypeScript types are always in sync with JSON Schemas. For greenfield projects, Zod offers a simpler single-source approach: define validation schemas with Zod and extract TypeScript types via z.infer<typeof Schema> — no separate generation step needed. Zod's z.discriminatedUnion() maps directly to a discriminated union TypeScript type with O(1) runtime validation.
Further reading and primary sources
- JSON Schema: Combining Schemas (allOf, anyOf, oneOf) — Official JSON Schema documentation on composition keywords with examples and behavioral rules
- AJV: JSON Schema Validator Documentation — AJV getting started guide, performance options, and standalone compilation for zero-overhead validation
- JSON Schema: $defs and $ref Reference — Official guide to structuring JSON Schemas with $defs, $ref, $id, and cross-file schema references
- json-schema-to-typescript on npm — Tool for generating TypeScript interfaces from JSON Schema, supporting $defs, allOf, anyOf, oneOf, and recursive schemas
- Zod discriminatedUnion Documentation — Zod discriminatedUnion() API — O(1) discriminated union validation with automatic TypeScript type inference