JSON Schema Generation in Zod v4#
Zod v4 exposes z.toJSONSchema(schema, params?) as a standalone function that converts any Zod schema to a JSON Schema document . It defaults to JSON Schema Draft 2020-12 and also targets "draft-07", "draft-04", and "openapi-3.0" . The function can also accept a $ZodRegistry to convert an entire registry of schemas at once .
Key source files:
- packages/zod/src/v4/core/to-json-schema.ts β core pipeline types and orchestration (
initializeContext,process,extractDefs,finalize) - packages/zod/src/v4/core/json-schema-processors.ts β all type-specific processors and the public
toJSONSchemaexport - packages/zod/src/v4/classic/tests/to-json-schema.test.ts β comprehensive behavioral tests
Pipeline#
Every call to toJSONSchema runs four sequential steps :
initializeContextβ builds aToJSONSchemaContextwith resolved defaults: target ("draft-2020-12"),unrepresentable: "throw",io: "output",cycles: "ref",reused: "inline".processβ recursively traverses the schema tree, dispatching each node to its type-specific processor viactx.processors[def.type]. Results are memoized in aseen: Map<ZodType, Seen>to detect cycles and reuse .extractDefsβ decides which schemas get lifted into$defs/definitions: schemas with ameta({ id }), cyclically-referenced schemas, and (whenreused: "ref") any schema seen more than once .finalizeβ flattens$refinheritance, strips internal registration tags, and runs theoverridecallback on every schema node. Returns a deep-cloned object with no circular references .
Type Processors#
The allProcessors record maps 37 schema type names to dedicated functions. Each has the signature (schema, ctx, json, params) => void and mutates the json output object in-place . Selected examples:
| Type | Behavior |
|---|---|
string | Sets type: "string", adds minLength, maxLength, format, pattern, contentEncoding |
number | Sets type: "number" or "integer", handles exclusive/inclusive bounds and multipleOf |
object | Builds properties, computes required via optin/optout flags, sets additionalProperties based on catchall |
tuple | Emits length constraints matching runtime validation. Closed tuples (without .rest()) set additionalItems: false (draft-04/07), items: false (draft-2020-12), and minItems/maxItems based on required/optional elements. See Tuple Constraints below. |
union | Uses anyOf (inclusive) or oneOf (discriminated) |
record | Emits propertyNames + additionalProperties, or patternProperties for regex key schemas |
Wrapper types (nullable, optional, default, readonly, pipe, lazy, etc.) set seen.ref to their inner type so the finalize step can inherit properties from that inner schema, then optionally add metadata fields (readOnly, default, etc.) to the JSON output .
Tuple Constraints#
Closed tuples (those without .rest()) emit length constraints that match the runtime validation behavior enforced by .parse() (#6194, fixes #6193):
- draft-04 / draft-07:
additionalItems: falseto reject extra elements beyond the defined positions - draft-2020-12:
items: falseto reject extra elements - All targets:
minItemsandmaxItemsbased on the tuple length
The minItems value respects the io context ("input" vs "output") by reading optin/optout flags from optional elements, matching the runtime parser behavior. For tuples with trailing optional elements, minItems will be less than maxItems to allow those optional elements to be omitted. Elements with .default() are also treated as optional in input mode.
const fixed = z.tuple([z.string(), z.number()]);
z.toJSONSchema(fixed, { target: "draft-2020-12" });
// => { prefixItems: [...], items: false, minItems: 2, maxItems: 2 }
const withOptional = z.tuple([z.string(), z.number().optional()]);
z.toJSONSchema(withOptional, { target: "draft-2020-12" });
// => { prefixItems: [...], items: false, minItems: 1, maxItems: 2 }
const withDefault = z.tuple([z.string(), z.string().default("x")]);
z.toJSONSchema(withDefault, { target: "draft-2020-12", io: "input" });
// => { minItems: 1, maxItems: 2 }
z.toJSONSchema(withDefault, { target: "draft-2020-12", io: "output" });
// => { minItems: 2, maxItems: 2 }
Open tuples (with .rest()) do not set items: false or additionalItems: false, and do not set maxItems, allowing any number of additional elements matching the rest schema.
Static vs Runtime Optin Reading#
The JSON Schema emitter reads the optin flag in two contexts: objectProcessor (for required) and tupleProcessor (for minItems). When io: "input" is used, the emitter describes the declared input type (what z.input<> shows).
Both processors read the static optin value by resolving past catch, transform, and preprocess to find the schema that actually carries the optionality . This ensures the required list and minItems both match the declared type. #5003 settled the policy that input JSON Schema describes what you should pass, not everything you can pass, and #6133 restored this policy for objects after #5939 and #5941 set the runtime flags. #6418 fixed the same issue for tuples. #6409 made isTransforming recurse through catch, so a catch no longer hides an inner transform from its ancestors.
z.toJSONSchema(z.tuple([z.string(), z.preprocess((v) => v, z.string())]), { io: "input" });
// minItems: 2, matching the declared type [string, string] and the object equivalent's required: ["a", "b"]
// (prior to #6418, this incorrectly emitted minItems: 1)
Output mode still reads optout directly in both processors β the static resolution is input-side only. A trailing .default() or .optional() still shortens minItems, since those declare optional input statically, and resolution passes through the wrapper rather than stopping at it, so a catch over an optional inner shortens too.
Metadata via .meta()#
.meta({ ... }) registers arbitrary metadata against a schema in globalRegistry. During process, that metadata is shallow-merged onto the JSON Schema output using an internal property-assignment function that safely handles all JavaScript property keys, including reserved keys like __proto__ .
The id property inside .meta({ id: "..." }) is a Zod-internal registration tag, not user-facing JSON Schema metadata. It controls how the schema is lifted into $defs and determines the $defs key. It is stripped from both the definition body and the root output before the final document is returned .
Root schema handling: When a root schema has .meta({ id }), it is hoisted into $defs and the root level becomes a $ref wrapper pointing to that definition. When a root schema has no id, it remains inline at the root level (the existing behavior). This affects how self-references work: an id-less root uses $ref: "#" to refer to itself, while a root with an id is extracted into $defs and self-references point to its $defs location.
const A = z.object({ name: z.string() }).meta({ id: "A" });
const result = z.toJSONSchema(A);
// {
// "$schema": "https://json-schema.org/draft/2020-12/schema",
// "$ref": "#/$defs/A",
// "$defs": {
// "A": {
// "type": "object",
// "properties": { "name": { "type": "string" } },
// "required": ["name"],
// "additionalProperties": false
// }
// }
// }
JSON Pointer Encoding: When id values contain the JSON Pointer reserved characters / or ~, the generated $ref pointers automatically escape them per RFC 6901: ~ becomes ~0 and / becomes ~1. The $defs key itself uses the original, unescaped id. This escaping applies to root-level $ref values as well as nested references. For example, a schema with .meta({ id: "Shared/User~" }) produces $ref: "#/$defs/Shared~1User~0" while the corresponding $defs key remains "Shared/User~".
Crucially, property-level annotations survive $ref extraction: when a schema with meta({ id }) is referenced and then wrapped with .describe("..."), the description annotation is preserved on the $ref object rather than being lost .
Metadata takes precedence over the keywords Zod generates.
z.toJSONSchema(z.string().meta({ type: "number" }));
// => { type: "number" }
z.toJSONSchema(z.date().meta({ type: "string", format: "date-time" }), { unrepresentable: "any" });
// => { type: "string", format: "date-time" }
To drop metadata entirely, pass an empty registry: z.toJSONSchema(schema, { metadata: z.registry() }). To modify a generated schema, use override.
$ref Flattening#
The finalize step's flattenRef function handles inheritance from wrapper/parent schemas. For each schema node that has a seen.ref pointer, it merges the referenced schema's properties into the current node's schema, then restores the current schema's own properties so the child wins on conflicts. For older targets (draft-07, draft-04, openapi-3.0) that cannot combine $ref with sibling properties, Zod wraps the ref in an allOf instead .
override β Final Mutation Hook#
The override callback is invoked once per schema node in finalize, after ref flattening, with access to { zodSchema, jsonSchema, path } . It is the correct place for arbitrary post-processing, including backfilling properties like additionalProperties that Zod omits by default in io: "input" mode . The id field is still visible to override callbacks before Zod strips it .
Note that unrepresentable types will throw an Error before this function is called. To represent one of them, use unrepresentable β a handler there substitutes a type without disabling the error for the others. Setting unrepresentable: "any" alongside override also works, but erases every unrepresentable type.
Unrepresentable Types and io Mode#
Types with no JSON Schema equivalent (bigint, symbol, void, undefined, Date, Map, Set, transforms) throw by default. Set unrepresentable: "any" to emit {} instead .
To decide case by case, pass a function instead. It's called for each unrepresentable schema encountered. Return a JSON Schema to use in its place, "any", or "throw" (the default when you return nothing).
z.toJSONSchema(z.object({ createdAt: z.date(), id: z.bigint() }), {
unrepresentable: ({ zodSchema }) =>
zodSchema._zod.def.type === "date" ? { type: "string", format: "date-time" } : "throw",
});
// => throws Error (BigInt cannot be represented in JSON Schema)
The handler also receives the path of the schema and the message Zod would have thrown, and any error it throws propagates, so you can report unrepresentable types in your own words.
z.toJSONSchema(schema, {
unrepresentable: ({ path, message }) => {
throw new Error(`${message} (at /${path.join("/")})`);
},
});
Two schemas can share a zodSchema but not a message β an undefined member and a bigint member of the same literal both arrive as the literal β so branch on message to tell those apart.
Returning a JSON Schema for a literal replaces the whole literal, dropping its representable members: z.literal(["a", 1n]) becomes just what you returned, and "a" is gone. Return "any" instead to keep the existing per-value behavior.
The io parameter ("output" default / "input") controls which side of transforms and pipes is emitted. When io: "input", examples and default from the output side are dropped . For z.pipe(), the output type is used by default; pass io: "input" to emit the input type .
Cycles and Reuse#
By default, cycles are broken by emitting a $ref to the root schema and leaving reused schemas inline. How self-references are encoded depends on whether the root schema has an id:
- Root schema without
id: It remains inline at the root level, and self-references use$ref: "#"(pointing to the inline root). - Root schema with
id: It gets hoisted into$defs, and self-references point to its$defslocation (e.g.,$ref: "#/$defs/A").
Pass cycles: "throw" to error on any cycle, or reused: "ref" to automatically extract all multiply-referenced schemas into $defs .