Composition keywords let you define shared sub-schemas and combine validation rules.
Supported keywords
| Keyword | What it does |
|---|
$defs / $ref | Define and reuse shared sub-schemas |
allOf | All subschemas must match |
anyOf / oneOf | Exactly one subschema must match |
not | The subschema must not match |
Reuse with $defs and $ref
Define shared sub-schemas once, then reference them.
{
"$defs": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zip": { "type": "string", "pattern": "^[0-9]{5}$" }
},
"required": ["street", "city", "zip"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"billing": { "$ref": "#/$defs/address" },
"shipping": { "$ref": "#/$defs/address" }
},
"required": ["billing", "shipping"],
"additionalProperties": false
}
from typing import Annotated
from pydantic import BaseModel, ConfigDict, StringConstraints
class Address(BaseModel):
model_config = ConfigDict(extra="forbid")
street: str
city: str
zip: Annotated[str, StringConstraints(pattern=r"^[0-9]{5}$")]
class Payload(BaseModel):
model_config = ConfigDict(extra="forbid")
billing: Address
shipping: Address
import { z } from "zod";
const addressSchema = z
.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^[0-9]{5}$/),
})
.strict();
const payloadSchema = z
.object({
billing: addressSchema,
shipping: addressSchema,
})
.strict();
$ref support is for local references (for example #/$defs/...).
allOf
Combine constraints that must all apply.
{
"allOf": [
{
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
},
{
"type": "object",
"properties": { "email": { "type": "string", "format": "email" } },
"required": ["email"]
}
]
}
import { z } from "zod";
const payloadSchema = z.intersection(
z.object({ name: z.string() }).strict(),
z.object({ email: z.string().email() }).strict(),
);
Pydantic example is omitted because there is no direct allOf keyword mapping.
anyOf / oneOf
Both anyOf and oneOf behave the same way: exactly one subschema must match. Use a discriminator field plus const to make branches explicit.
{
"oneOf": [
{
"type": "object",
"properties": {
"tool": { "const": "search" },
"query": { "type": "string", "minLength": 1 }
},
"required": ["tool", "query"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"tool": { "const": "lookup" },
"id": { "type": "integer" }
},
"required": ["tool", "id"],
"additionalProperties": false
}
]
}
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
class SearchTool(BaseModel):
model_config = ConfigDict(extra="forbid")
tool: Literal["search"]
query: str = Field(min_length=1)
class LookupTool(BaseModel):
model_config = ConfigDict(extra="forbid")
tool: Literal["lookup"]
id: int
ToolCall = Annotated[SearchTool | LookupTool, Field(discriminator="tool")]
import { z } from "zod";
const toolCallSchema = z.discriminatedUnion("tool", [
z
.object({
tool: z.literal("search"),
query: z.string().min(1),
})
.strict(),
z
.object({
tool: z.literal("lookup"),
id: z.number().int(),
})
.strict(),
]);
not
Reject values that match a subschema:
{
"type": "object",
"properties": {
"value": {
"type": "string",
"not": { "const": "" }
}
},
"required": ["value"],
"additionalProperties": false
}
Pydantic and Zod examples are omitted here because there is no direct keyword mapping for JSON Schema not.
This accepts any non-empty string.
Recursive schemas
Recursive local references are supported:
{
"$defs": {
"node": {
"type": "object",
"properties": {
"name": { "type": "string" },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" },
"maxItems": 10
}
},
"required": ["name", "children"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"root": { "$ref": "#/$defs/node" }
},
"required": ["root"],
"additionalProperties": false
}
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class Node(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
children: list[Node] = Field(..., max_length=10)
class Payload(BaseModel):
model_config = ConfigDict(extra="forbid")
root: Node
Payload.model_rebuild()
import { z } from "zod";
type Node = {
name: string;
children: Node[];
};
const nodeSchema: z.ZodType<Node> = z.object({
name: z.string(),
children: z.array(z.lazy(() => nodeSchema)).max(10),
}).strict();
const payloadSchema = z.object({
root: nodeSchema,
}).strict();