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).

05_registries_and_json_schema_1
Figure 5.1: Zod v4 Three-Pass toJSONSchema Synthesis Pipeline & Dialect Emitter Architecture
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:

  1. _map: WeakMap<Schema, $replace<Meta, Schema>>: Ephemeral schemas registered without an id are 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.
  2. _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-registry z.toJSONSchema(myRegistry) calls can enumerate named schemas.
  3. Parent Chain Metadata Inheritance (get(schema)): Cloning methods like .describe() or .optional() set clone._zod.parent = inst. When registry.get(schema) walks schema._zod.parent, it merges inherited metadata ({ ...pm, ...this._map.get(schema) }) while explicitly deleting pm.id (delete pm.id; // do not inherit id) so derived schemas do not collide with their parent's $defs identifier.
  4. Dual-Package Hazard Prevention (globalThis.__zod_globalRegistry): globalRegistry is anchored on globalThis.__zod_globalRegistry so 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:

  1. ctx.seen Reference & Cycle Tracking: Every visited $ZodType is recorded in ctx.seen: Map<$ZodType, Seen>. On a repeat visit, seen.count++ is incremented, and if params.schemaPath.includes(schema) is true, seen.cycle = params.path marks the schema as recursive.
  2. Order-Independent Check Aggregation (aggregateChecks in json-schema-processors.ts): Instead of reading _zod.bag directly (which depends on the order onattach callbacks ran), aggregateChecks(schema) folds def.checks as a logical conjunction:
    • narrowMin / narrowMax: Tightest numeric or length bound wins (value > agg[key] for minimums, value < agg[key] for maximums).
    • addDivisor: Collects all multiple_of divisors; the first lands on json.multipleOf and additional divisors emit into json.allOf.
    • addPattern: Collects all RegExp patterns; multiple patterns on a string emit into json.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))).
  3. Static Optionality Resolution (inputOptin): When ctx.io === "input", objectProcessor and tupleProcessor call inputOptin(field) to look past runtime wrappers ($ZodPipe transforms and $ZodCatch) that set optin = "optional" solely to observe absent keys at runtime, preserving the schema's true static required contract.

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 with seen.cycle raises an error showing the cycle path.
  • Otherwise, extractToDef(entry) extracts any schema that:
    1. Belongs to ctx.external.registry with an id,
    2. Carries an explicit id in ctx.metadataRegistry,
    3. Participates in a cycle (seen.cycle is defined), or
    4. Was encountered multiple times (seen.count > 1) when ctx.reused === "ref".
  • Extracted schemas move their body into seen.def and replace seen.schema in-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:

  1. Reverse-Order flattenRef(zodSchema): Wrapper schemas (optional, readonly, default, prefault, catch, pipe, lazy) set seen.ref = def.innerType during Pass 1. flattenRef walks ctx.seen in reverse order, merging inherited properties from seen.ref and _zod.parent. On legacy targets (draft-07, draft-04, openapi-3.0) that forbid sibling keywords alongside $ref, it wraps the $ref inside schema.allOf = [{ $ref }].
  2. compactTypeUnion(schema): On non-OpenAPI 3.0 targets, rewrites bare type unions like anyOf: [{ type: "string" }, { type: "null" }] into the compact array form type: ["string", "null"].
  3. ctx.deferred & rewriteKeyNames(ctx): Re-expresses numeric z.record(z.number(), V) propertyNames schemas into string schemas with numeric regex patterns (regexes.number / regexes.integer) because JSON object keys are always strings.
  4. Structural Intersection Folding (foldIntersection): In JSON Schema, combining two closed objects (additionalProperties: false) inside allOf creates an unsatisfiable schema because each branch rejects the other's properties. foldIntersection() pools the properties, required sets, and additionalProperties / catchall constraints of foldable object members (and distributes over a single anyOf/oneOf union: $A \land (X \lor Y) \Rightarrow (A \land X) \lor (A \land Y)$) into a single unified object schema.
  5. Standard JSON Schema Attachment (~standard.jsonSchema): Finally, finalize() deep-clones the result via JSON.parse(JSON.stringify(result)) and attaches a non-enumerable ~standard property implementing StandardJSONSchemaV1.

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.

05_registries_and_json_schema_2
Figure 5.2: Reverse Compilation Pipeline in fromJSONSchema (from-json-schema.ts)

1. Input Sanitization & Local $ref Cycle Resolution (resolveRef)

  1. JSON Normalization Gate: fromJSONSchema first passes the input schema through JSON.parse(JSON.stringify(schema)) to guarantee a finite, plain object graph free of JavaScript reference cycles, getters, or Proxy traps.
  2. RFC 6901 Decoding & ctx.processing Cycle Breaking:
    • Local $ref strings (# or #/$defs/<key> / #/definitions/<key>) are decoded via decodeJSONPointerSegment(segment) (~1 $\rightarrow$ / before ~0 $\rightarrow$ ~, exact inverse of encodeJSONPointerSegment).
    • Memoized targets live in ctx.refs: Map<string, ZodType>. If ctx.processing.has(refPath) is true when a $ref is encountered, the compiler detects a recursive cycle and returns z.lazy(() => ctx.refs.get(refPath)!).

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): Inspects Object.getOwnPropertyNames(value) on the raw object to validate minProperties, maxProperties, and keySchema.safeParse(key) (emitting invalid_key issues on failure) before piping into objectSchema.
  • checkArrayGuards(arraySchema, guards):
    • For uniqueItems: true, computes an injective length-prefixed serialization via canonicalKey(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 a Map<string, number> lookup instead of $O(N^2)$ pairwise deep comparisons.
    • For contains, counts items satisfying containsSchema.safeParse(item).success with early exit at maxContains + 1.

3. Composition Layering & Lossless Registry Round-Tripping

After convertBaseSchema(schema, ctx) builds the primary ZodType, convertSchema layers composition and metadata keywords in strict order:

  1. anyOf $\rightarrow$ z.union(...) and oneOf $\rightarrow$ z.xor(...): If the schema also declared an explicit type, enum, or const, the union is intersected (z.intersection(baseSchema, union)) with the base constraint.
  2. allOf $\rightarrow$ chained z.intersection(...).
  3. OpenAPI nullable: true $\rightarrow$ z.nullable(baseSchema), readOnly: true $\rightarrow$ z.readonly(baseSchema), and default $\rightarrow$ baseSchema.default(schema.default).
  4. Metadata Preservation: Core JSON Schema annotations ($id, id, $comment, contentEncoding, etc.), guard keywords (propertyNames, minProperties, uniqueItems, contains when free of $ref), and any custom unrecognized keys (!RECOGNIZED_KEYS.has(key)) are registered into ctx.registry.add(baseSchema, extraMeta) before applying .describe(schema.description) last.

← 4. Doc & JIT Schema Compiler | 6. Type-Level Inference & Locales →