JSON Schema Patterns: Reusable Schemas, Composition, and Real-World Examples
Last updated:
JSON Schema becomes much more powerful when you move beyond flat object validation and leverage composition, reuse, and conditional logic. This guide covers the patterns that appear repeatedly in production API schemas — from simple $defs extraction to recursive tree schemas and discriminated unions.
Reusable Definitions with $defs
$defs is a vocabulary-only keyword — it stores named schemas that are inert until referenced with $ref. It's the single most important pattern for keeping schemas DRY.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"Address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"country": { "type": "string", "pattern": "^[A-Z]{2}$" },
"zip": { "type": "string", "pattern": "^[0-9]{5}(-[0-9]{4})?$" }
},
"required": ["street", "city", "country"]
},
"Money": {
"type": "object",
"properties": {
"amount": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" }
},
"required": ["amount", "currency"]
},
"Timestamp": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 UTC string"
}
},
"type": "object",
"properties": {
"billingAddress": { "$ref": "#/$defs/Address" },
"shippingAddress": { "$ref": "#/$defs/Address" },
"total": { "$ref": "#/$defs/Money" },
"tax": { "$ref": "#/$defs/Money" },
"createdAt": { "$ref": "#/$defs/Timestamp" }
}
}Both billingAddress and shippingAddress reference the same Address definition. Update the definition once and all references pick up the change.
allOf: Schema Inheritance
allOf requires data to satisfy all listed schemas simultaneously — it is JSON Schema's intersection type.
{
"$defs": {
"BaseUser": {
"type": "object",
"properties": {
"id": { "type": "string" },
"email": { "type": "string", "format": "email" },
"createdAt": { "$ref": "#/$defs/Timestamp" }
},
"required": ["id", "email"]
// Do NOT add additionalProperties: false here — it breaks allOf composition
},
"AdminUser": {
"allOf": [
{ "$ref": "#/$defs/BaseUser" },
{
"properties": {
"adminLevel": { "type": "integer", "minimum": 1, "maximum": 5 },
"permissions": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["adminLevel"],
"unevaluatedProperties": false // Use this, not additionalProperties: false
}
]
}
}
}Discriminated Unions with oneOf
A discriminated union validates different object shapes based on a shared type field.
// Webhook event — different payload per event type
{
"$defs": {
"PaymentEvent": {
"type": "object",
"properties": {
"type": { "const": "payment.succeeded" },
"amount": { "type": "number" },
"currency":{ "type": "string" }
},
"required": ["type", "amount", "currency"]
},
"UserEvent": {
"type": "object",
"properties": {
"type": { "const": "user.created" },
"userId": { "type": "string" },
"email": { "type": "string", "format": "email" }
},
"required": ["type", "userId", "email"]
},
"RefundEvent": {
"type": "object",
"properties": {
"type": { "const": "payment.refunded" },
"amount": { "type": "number" },
"reason": { "type": "string" }
},
"required": ["type", "amount"]
}
},
"oneOf": [
{ "$ref": "#/$defs/PaymentEvent" },
{ "$ref": "#/$defs/UserEvent" },
{ "$ref": "#/$defs/RefundEvent" }
]
}The const keyword on the type field acts as the discriminator. Ajv selects the matching branch when "type": "payment.succeeded" matches only PaymentEvent.
Alternative: if/then for Conditional Fields
// Require different fields based on shipping method
{
"type": "object",
"properties": {
"shippingMethod": { "enum": ["digital", "standard", "express"] }
},
"required": ["shippingMethod"],
"if": {
"properties": { "shippingMethod": { "const": "digital" } }
},
"then": {
"properties": { "downloadUrl": { "type": "string", "format": "uri" } },
"required": ["downloadUrl"]
},
"else": {
"properties": {
"address": { "$ref": "#/$defs/Address" },
"trackingEmail": { "type": "string", "format": "email" }
},
"required": ["address"]
}
}Recursive Schemas
Self-referential $ref enables validation of arbitrary-depth tree structures.
// Category tree — each category can have subcategories
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"Category": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"slug": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/Category" } // recursive!
}
},
"required": ["id", "name", "slug"]
}
},
"$ref": "#/$defs/Category"
}
// Valid data:
{
"id": "electronics",
"name": "Electronics",
"slug": "electronics",
"children": [
{
"id": "phones",
"name": "Phones",
"slug": "phones",
"children": [
{ "id": "android", "name": "Android", "slug": "android", "children": [] }
]
}
]
}Dynamic String Maps
// Map of currency code → amount (all string keys, all number values)
{
"type": "object",
"additionalProperties": { "type": "number", "minimum": 0 }
}
// Validates: { "USD": 1.0, "EUR": 0.85, "GBP": 0.72 }
// Map with key pattern constraint (2-letter ISO country codes only)
{
"type": "object",
"patternProperties": {
"^[A-Z]{2}$": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
}
// Map with some fixed keys + dynamic keys
{
"type": "object",
"properties": {
"default": { "type": "string" }
},
"required": ["default"],
"additionalProperties": { "type": "string" }
// "default" is required; any other string key maps to a string
}Common API Schema Patterns
{
"$defs": {
// Pagination query params
"PaginationParams": {
"type": "object",
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 },
"cursor": { "type": "string" }
}
},
// Stripe-style resource ID
"ResourceId": {
"type": "string",
"pattern": "^[a-z]+_[0-9a-zA-Z]{16,}$",
"examples": ["usr_1a2b3c4d5e6f7g8h"]
},
// ISO 4217 currency code
"CurrencyCode": {
"type": "string",
"pattern": "^[A-Z]{3}$",
"examples": ["USD", "EUR", "GBP"]
},
// PATCH body — all optional, at least one must be present
"UserPatchBody": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" },
"plan": { "enum": ["free", "pro", "enterprise"] }
},
"minProperties": 1,
"additionalProperties": false
},
// API error envelope
"ApiError": {
"type": "object",
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"details": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": { "type": "string" },
"message": { "type": "string" }
}
}
}
},
"required": ["code", "message"]
}
}
}additionalProperties vs unevaluatedProperties
| Keyword | Sees properties from allOf/$ref? | Use when |
|---|---|---|
additionalProperties: false | No — only same-level properties | Standalone schemas, no composition |
unevaluatedProperties: false | Yes — sees the full composition tree | allOf/oneOf schemas, Draft 2019-09+ |
FAQ
How do I create reusable schema components with $defs?
Place $defs at the root of your schema document with named keys. Reference them anywhere with {"$ref": "#/$defs/Name"}. The $defs block itself validates nothing — it's consulted only when referenced. In multi-file setups, reference external files: {"$ref": "./address.schema.json"}. This is the primary DRY mechanism in JSON Schema and the foundation of all schema libraries like OpenAPI component schemas.
What is a discriminated union in JSON Schema?
A discriminated union uses a shared literal field (the discriminator, typically type or kind) to determine which variant of a schema applies. Model it with oneOf where each variant uses const on the discriminator field. When Ajv validates {"type": "payment.succeeded", ...}, only the PaymentEvent branch matches the "const": "payment.succeeded" constraint, so only that branch's validation rules apply. In OpenAPI 3.1, the discriminator keyword names the discriminator field and provides a mapping table, giving generated SDKs a hint for efficient type switching.
How do I write a recursive JSON Schema for a tree structure?
Define the recursive type in $defs and reference it from within itself. In the children array's items, use {"$ref": "#/$defs/Category"}. The root schema is just {"$ref": "#/$defs/Category"}. Ajv handles circular references correctly by resolving them lazily. In Draft 2020-12, $dynamicRef with $dynamicAnchor provides a more powerful pattern for extensible recursive schemas where subschemas can override the recursive behavior.
How do I validate an array where all items must be unique objects?
Use uniqueItems: true for deep equality uniqueness. For uniqueness by a specific field (e.g., unique by id), JSON Schema has no built-in keyword — use a custom Ajv keyword or validate programmatically after schema validation. With Ajv, you can register a custom keyword that checks uniqueness on a specific property: ajv.addKeyword({ keyword: "uniqueBy", ... }). This separation of concerns — schema validation for type/format, programmatic validation for semantic constraints — is a common pattern in production validation pipelines.
How do I use allOf to extend a base schema?
allOf requires the data to match all listed schemas. Use it with $ref to a base definition plus an inline object adding new properties. Critical: never put additionalProperties: false in the base schema — it will reject the extra properties added by the extending schema. Instead, put unevaluatedProperties: false in the final composed schema. The TypeScript mental model: allOf: [A, B] corresponds to type AB = A & B.
What is the difference between additionalProperties: false and unevaluatedProperties: false?
additionalProperties: false only sees properties defined at the same schema level — it doesn't look into allOf, anyOf, or referenced schemas. This causes false failures when composing schemas. unevaluatedProperties: false (Draft 2019-09+, Ajv 8+) scans the entire composition tree and only rejects properties not touched by any subschema. Always use unevaluatedProperties when composing schemas with allOf or $ref; use additionalProperties only for leaf schemas with no composition.
How do I validate a JSON object where keys are dynamic (string map)?
Use additionalProperties with a schema to validate all values: {"type": "object", "additionalProperties": {"type": "number"}}. For key pattern constraints, use patternProperties: {"patternProperties": {"^[A-Z]{3}$": {"type": "number"}}} with additionalProperties: false. This is the JSON Schema equivalent of TypeScript's Record<string, number> (additionalProperties) or Record<"USD" | "EUR", number> (constrained with enum or pattern).
What are common JSON Schema patterns for API validation?
Build a shared $defs library with: pagination params (limit integer 1–100 + cursor string), resource ID pattern (regex for your ID format), ISO timestamp (string + format: date-time), money (number + currency string), email (string + format: email + maxLength: 254), and PATCH body (all properties optional + minProperties: 1). Reference these from every endpoint schema. This creates a consistent vocabulary across your entire API — the JSON Schema equivalent of a TypeScript type library. Tools like Redocly and Spectral can lint your OpenAPI specs to enforce that endpoints use these shared types instead of duplicating inline definitions.
Further reading and primary sources
- JSON Schema Structuring with $defs and $ref — Official JSON Schema docs on $defs, $ref, $id, and multi-file schemas
- JSON Schema Composition: allOf, anyOf, oneOf — Official documentation on combining schemas
- JSON Schema $ref and $defs (Jsonic) — Reusable definitions, recursive schemas, $anchor, and multi-file references
- JSON Schema if/then/else (Jsonic) — Conditional validation: required fields by value, country-based patterns, allOf multi-conditions
- Ajv JSON Schema Validator (Jsonic) — Compile schemas, add custom keywords, and integrate TypeScript types with Ajv