5. WeakMap Registries & JSON Schema Bidirectional Engine
Zod v4 replaces external third-party converters (zod-to-json-schema, json-schema-to-zod) with a native, first-party bidirectional JSON Schema synthesis and compilation engine coupled to a garbage-collection-safe WeakMap metadata registry system (packages/zod/src/v4/core/registries.ts, to-json-schema.ts, json-schema-processors.ts, json-schema-generator.ts, and packages/zod/src/v4/classic/from-json-schema.ts).
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, ChevronProcess, Panel, Table
from drawlib.styles import Styles
setup(width=164, height=86)
# Top Pipeline: 4-Stage toJSONSchema Synthesis Flow
pipeline = ChevronProcess(
style=Styles.Neutral,
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
corner_angle=55.0,
spacing=1.5,
flat_left_end=True,
)
pipeline.add("1. Context & Registry", description="initializeContext()")
pipeline.add("2. Process & Seen Map", description="processSchema(schema)", style=Styles.PrimaryNeutral)
pipeline.add(
"3. Extract $defs",
description="extractDefs(ctx, root)",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=11.0),
description_style=Styles.White.patch(text_size=10.0),
)
pipeline.add("4. Flatten & Finalize", description="finalize(ctx, root)")
pipeline.draw(xy=(6, 67), width=152, height=14)
# Bottom Left Panel: 3-Pass Graph Synthesis Mechanics
left_panel = Panel(
"3-Pass Graph Synthesis",
"to-json-schema.ts",
style=Styles.Neutral,
header_style=Styles.PrimaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.graph,
icon_style=Styles.PrimaryBold,
header_variant="filled",
header_height=8.5,
)
left_panel.draw((6.0, 5.0), width=75.0, height=58.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box((6.0, 5.0), width=75.0, height=58.0, padding=(3.0, 2.5))
pass_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_shape="rounded_rect",
badge_style=Styles.PrimaryNeutral,
badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
spacing=2.2,
)
pass_cards.add(
"Pass 1: processSchema() & aggregateChecks()",
"Folds checks, tracks ctx.seen counts & cycles",
badge_text="P1",
)
pass_cards.add(
"Pass 2: extractDefs() & RFC 6901 Escaping",
"Hoists id, cyclic & reused schemas to $defs",
badge_text="P2",
badge_style=Styles.PrimaryFlat,
badge_text_style=Styles.WhiteBold.patch(text_size=10.0),
)
pass_cards.add(
"Pass 3: finalize() & foldIntersection()",
"Flattens parent refs, compacts anyOf & allOf",
badge_text="P3",
)
pass_cards.draw(l_xy, width=l_w, height=l_h)
# Bottom Right Panel: 4 Target Dialect Matrix
right_panel = Panel(
"Target Dialect Matrix",
"4 JSON Schema Targets",
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.sliders_horizontal,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=8.5,
)
right_panel.draw((85.0, 5.0), width=73.0, height=58.0, align="bottom_left")
dialect_table = Table(
cell_style=Styles.White,
text_style=Styles.Dark.patch(text_size=10.0),
header_cell_style=Styles.PrimaryFlat,
header_text_style=Styles.WhiteBold.patch(text_size=10.0),
border_style=Styles.MutedThin,
)
dialect_table.draw(
xy=(88.5, 51.5),
width=66.0,
height=43.5,
data=[
["Target", "Defs Key", "Tuple / Nullable"],
["draft-2020-12", "$defs", "prefixItems / type[]"],
["draft-07", "definitions", "items[] / type[]"],
["draft-04", "definitions", "exclusiveMin: bool"],
["openapi-3.0", "definitions", "nullable: true"],
],
)
save()
5.1 WeakMap Metadata Registries (packages/zod/src/v4/core/registries.ts)
In Zod v3, attaching documentation or OpenAPI metadata to a schema required mutating _def.description or bolting on third-party monkey-patches. Zod v4 decouples schema definitions from arbitrary metadata via $ZodRegistry<Meta, Schema> in packages/zod/src/v4/core/registries.ts.
1. Type-Level Phantom Substitution ($output, $input, and $replace<Meta, S>)
A major ergonomic challenge in strongly-typed registries is constraining metadata fields (such as examples: $output[] or defaultValue: $input) to the exact inferred type of the schema being registered. Zod v4 solves this at the type level using two unique phantom symbols ($output and $input) and a recursive type-level AST mapper $replace<Meta, S>:
// packages/zod/src/v4/core/registries.ts
export const $output: unique symbol = /*@__PURE__*/ Symbol("ZodOutput");
export type $output = typeof $output;
export const $input: unique symbol = /*@__PURE__*/ Symbol("ZodInput");
export type $input = typeof $input;
export type $replace<Meta, S extends $ZodType> = Meta extends $output
? core.output<S>
: Meta extends $input
? core.input<S>
: Meta extends (infer M)[]
? $replace<M, S>[]
: Meta extends (...args: infer P) => infer R
? (
...args: {
[K in keyof P]: $replace<P[K], S>; // tuple
}
) => $replace<R, S>
: Meta extends object
? { [K in keyof Meta]: $replace<Meta[K], S> }
: Meta;
When a developer declares z.registry<{ examples: z.$output[] }>(), calling .add(z.string(), { examples: ["hello"] }) recursively substitutes $output with core.output<ZodString> (string) across arrays, function parameter tuples, and nested objects.
2. Garbage-Collection Safety (_map: WeakMap), _idmap, and Parent Inheritance
$ZodRegistry maintains two internal stores:
_map: WeakMap<Schema, $replace<Meta, Schema>>: Ephemeral schemas registered without anidare held weakly. Once a schema goes out of lexical scope, V8 can garbage-collect both the schema instance and its metadata entry without memory leaks._idmap: Map<string, Schema>: When metadata includes an"id"key (if (meta && typeof meta === "object" && "id" in meta)),_idmap.set(meta.id!, schema)retains a strong lookup reference so whole-registryz.toJSONSchema(myRegistry)calls can enumerate named schemas.- Parent Chain Metadata Inheritance (
get(schema)): Cloning methods like.describe()or.optional()setclone._zod.parent = inst. Whenregistry.get(schema)walksschema._zod.parent, it merges inherited metadata ({ ...pm, ...this._map.get(schema) }) while explicitly deletingpm.id(delete pm.id; // do not inherit id) so derived schemas do not collide with their parent's$defsidentifier. - Dual-Package Hazard Prevention (
globalThis.__zod_globalRegistry):globalRegistryis anchored onglobalThis.__zod_globalRegistryso that CommonJS (require("zod")) and ESM (import "zod") bundles loaded in the same runtime share a single deduplicated registry instance.
5.2 Forward Synthesis: toJSONSchema & json-schema-processors.ts
Whether invoked via the functional toJSONSchema(schemaOrRegistry, params) API or the legacy JSONSchemaGenerator class (packages/zod/src/v4/core/json-schema-generator.ts), JSON Schema generation executes a 3-pass graph algorithm over ToJSONSchemaContext:
Pass 1: processSchema() and Conjunction Check Folding (aggregateChecks)
In packages/zod/src/v4/core/to-json-schema.ts, processSchema(schema, ctx, params) (renamed from process to prevent collisions with bundler const process polyfills) performs a depth-first walk:
ctx.seenReference & Cycle Tracking: Every visited$ZodTypeis recorded inctx.seen: Map<$ZodType, Seen>. On a repeat visit,seen.count++is incremented, and ifparams.schemaPath.includes(schema)is true,seen.cycle = params.pathmarks the schema as recursive.- Order-Independent Check Aggregation (
aggregateChecksinjson-schema-processors.ts): Instead of reading_zod.bagdirectly (which depends on the orderonattachcallbacks ran),aggregateChecks(schema)foldsdef.checksas a logical conjunction:narrowMin/narrowMax: Tightest numeric or length bound wins (value > agg[key]for minimums,value < agg[key]for maximums).addDivisor: Collects allmultiple_ofdivisors; the first lands onjson.multipleOfand additional divisors emit intojson.allOf.addPattern: Collects allRegExppatterns; multiple patterns on a string emit intojson.allOf(with{ type: "string", pattern }on legacy drafts).intersectMime: Intersects allowed MIME type arrays across stacked.mime()checks (agg.mime.filter(m => mime.includes(m))).
- Static Optionality Resolution (
inputOptin): Whenctx.io === "input",objectProcessorandtupleProcessorcallinputOptin(field)to look past runtime wrappers ($ZodPipetransforms and$ZodCatch) that setoptin = "optional"solely to observe absent keys at runtime, preserving the schema's true staticrequiredcontract.
Pass 2: extractDefs() and RFC 6901 JSON Pointer Escaping
After Pass 1 populates ctx.seen, extractDefs(ctx, schema) scans all visited entries:
- First, it verifies no two distinct schemas share the same
.meta({ id })string, throwing a duplicate ID error if so. - Next, if
ctx.cycles === "throw", any entry withseen.cycleraises an error showing the cycle path. - Otherwise,
extractToDef(entry)extracts any schema that:- Belongs to
ctx.external.registrywith anid, - Carries an explicit
idinctx.metadataRegistry, - Participates in a cycle (
seen.cycleis defined), or - Was encountered multiple times (
seen.count > 1) whenctx.reused === "ref".
- Belongs to
- Extracted schemas move their body into
seen.defand replaceseen.schemain-place with{ $ref: "#/..." }using RFC 6901 JSON Pointer escaping (encodeJSONPointerSegment:~$\rightarrow$~0,/$\rightarrow$~1) under#/$defs/(draft-2020-12) or#/definitions/(draft-07,draft-04,openapi-3.0).
Pass 3: finalize() — Ref Flattening, Union Compaction, and Intersection Folding
finalize(ctx, schema) runs four post-processing passes before serializing the output:
- Reverse-Order
flattenRef(zodSchema): Wrapper schemas (optional,readonly,default,prefault,catch,pipe,lazy) setseen.ref = def.innerTypeduring Pass 1.flattenRefwalksctx.seenin reverse order, merging inherited properties fromseen.refand_zod.parent. On legacy targets (draft-07,draft-04,openapi-3.0) that forbid sibling keywords alongside$ref, it wraps the$refinsideschema.allOf = [{ $ref }]. compactTypeUnion(schema): On non-OpenAPI 3.0 targets, rewrites bare type unions likeanyOf: [{ type: "string" }, { type: "null" }]into the compact array formtype: ["string", "null"].ctx.deferred&rewriteKeyNames(ctx): Re-expresses numericz.record(z.number(), V)propertyNamesschemas into string schemas with numeric regex patterns (regexes.number/regexes.integer) because JSON object keys are always strings.- Structural Intersection Folding (
foldIntersection): In JSON Schema, combining two closed objects (additionalProperties: false) insideallOfcreates an unsatisfiable schema because each branch rejects the other's properties.foldIntersection()pools theproperties,requiredsets, andadditionalProperties/catchallconstraints of foldable object members (and distributes over a singleanyOf/oneOfunion: $A \land (X \lor Y) \Rightarrow (A \land X) \lor (A \land Y)$) into a single unified object schema. - Standard JSON Schema Attachment (
~standard.jsonSchema): Finally,finalize()deep-clones the result viaJSON.parse(JSON.stringify(result))and attaches a non-enumerable~standardproperty implementingStandardJSONSchemaV1.
5.3 Reverse Compilation: fromJSONSchema (packages/zod/src/v4/classic/from-json-schema.ts)
The reverse compiler fromJSONSchema(schema, params) in packages/zod/src/v4/classic/from-json-schema.ts reconstructs a runtime ZodType AST from a JSON Schema document across draft-2020-12, draft-7, draft-4, and openapi-3.0.
1. Input Sanitization & Local $ref Cycle Resolution (resolveRef)
- JSON Normalization Gate:
fromJSONSchemafirst passes the input schema throughJSON.parse(JSON.stringify(schema))to guarantee a finite, plain object graph free of JavaScript reference cycles, getters, or Proxy traps. - RFC 6901 Decoding &
ctx.processingCycle Breaking:- Local
$refstrings (#or#/$defs/<key>/#/definitions/<key>) are decoded viadecodeJSONPointerSegment(segment)(~1$\rightarrow$/before~0$\rightarrow$~, exact inverse ofencodeJSONPointerSegment). - Memoized targets live in
ctx.refs: Map<string, ZodType>. Ifctx.processing.has(refPath)is true when a$refis encountered, the compiler detects a recursive cycle and returnsz.lazy(() => ctx.refs.get(refPath)!).
- Local
2. Pre-Pipe Instance Guards (checkObjectGuards & checkArrayGuards)
A subtle semantic discrepancy between JSON Schema and Zod is that Zod's z.object() strips unrecognized keys (or populates .default() properties) during parsing, and z.array() populates item defaults. However, JSON Schema defines minProperties, maxProperties, propertyNames, uniqueItems, and contains (minContains / maxContains) over the raw unparsed input instance.
To enforce these keywords before inner defaults or key stripping alter the value—while still allowing a subsequent toJSONSchema() call to see the inner object/array type on both input and output—fromJSONSchema prepends an identity transform pipe (z.transform((value: unknown) => value).check(...).pipe(innerSchema)):
checkObjectGuards(objectSchema, guards): InspectsObject.getOwnPropertyNames(value)on the raw object to validateminProperties,maxProperties, andkeySchema.safeParse(key)(emittinginvalid_keyissues on failure) before piping intoobjectSchema.checkArrayGuards(arraySchema, guards):- For
uniqueItems: true, computes an injective length-prefixed serialization viacanonicalKey(item, new Set())(where keys in objects are sorted and strings/keys are prefixed with${length}:) so duplicate detection runs in $O(N)$ time via aMap<string, number>lookup instead of $O(N^2)$ pairwise deep comparisons. - For
contains, counts items satisfyingcontainsSchema.safeParse(item).successwith early exit atmaxContains + 1.
- For
3. Composition Layering & Lossless Registry Round-Tripping
After convertBaseSchema(schema, ctx) builds the primary ZodType, convertSchema layers composition and metadata keywords in strict order:
anyOf$\rightarrow$z.union(...)andoneOf$\rightarrow$z.xor(...): If the schema also declared an explicittype,enum, orconst, the union is intersected (z.intersection(baseSchema, union)) with the base constraint.allOf$\rightarrow$ chainedz.intersection(...).- OpenAPI
nullable: true$\rightarrow$z.nullable(baseSchema),readOnly: true$\rightarrow$z.readonly(baseSchema), anddefault$\rightarrow$baseSchema.default(schema.default). - Metadata Preservation: Core JSON Schema annotations (
$id,id,$comment,contentEncoding, etc.), guard keywords (propertyNames,minProperties,uniqueItems,containswhen free of$ref), and any custom unrecognized keys (!RECOGNIZED_KEYS.has(key)) are registered intoctx.registry.add(baseSchema, extraMeta)before applying.describe(schema.description)last.
← 4. Doc & JIT Schema Compiler | 6. Type-Level Inference & Locales →