5. WeakMap レジストリと JSON Schema 双方向エンジン

Zod v4 はスキーマのイントロスペクション、メタデータ付与、および JSON Schema の双方向変換(toJSONSchema / fromJSONSchema)を第一級のコアサブシステムとして統合しています。スキーマインスタンス自体にドキュメント用プロパティを直接持たせてメモリフットプリントを肥大化させる代わりに、Zod v4 はガベージコレクション安全な WeakMap バックエンドのレジストリ(packages/zod/src/v4/core/registries.ts)にメタデータを分離し、3 パスのグラフ合成パイプライン(packages/zod/src/v4/core/to-json-schema.ts、packages/zod/src/v4/core/json-schema-processors.ts)および逆コンパイラ(packages/zod/src/v4/classic/from-json-schema.ts)と連携させます。

05_registries_and_json_schema_1
図 5.1: Zod v4 の 3 パス JSON Schema 合成パイプラインとターゲット方言マトリクス
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. コンテキスト初期化", description="initializeContext()")
pipeline.add("2. 走査と Seen マップ", description="processSchema(schema)", style=Styles.PrimaryNeutral)
pipeline.add(
    "3. $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. 参照展開と確定", 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 パスグラフ合成エンジン",
    "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()",
    "チェックの畳み込みと ctx.seen 循環記録",
    badge_text="P1",
)
pass_cards.add(
    "Pass 2: extractDefs() / RFC 6901 エスケープ",
    "id・循環・再利用スキーマを $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()",
    "親参照の展開、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(
    "ターゲット方言マトリクス",
    "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=[
        ["ターゲット", "定義キー", "タプル / 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 レジストリと $replace<Meta, S> 型レベル置換 (registries.ts)

packages/zod/src/v4/core/registries.ts において、$ZodRegistry<Meta, Schema> はスキーマ定義($ZodType)とオープンエンドなメタデータ(GlobalMeta やカスタムレジストリスキーマ)を疎結合に結び付けます。

1. $output / $input センチネルと $replace<Meta, S>

レジストリメタデータの型を、登録対象のスキーマ自身の入力型または出力型に依存させたいケースがよくあります(例:examples: z.$output[] や defaultTransform: (val: z.$input) => z.$output)。TypeScript には高階型(Higher-Kinded Types: HKT)が存在しないため、Zod v4 は 2 つのユニークシンボルセンチネル($output: unique symbol と $input: unique symbol)および再帰的な条件付き型置換ユーティリティ $replace<Meta, S> を定義しています。

// packages/zod/src/v4/core/registries.ts
export const $output: unique symbol = Symbol("ZodOutput");
export type $output = typeof $output;
export const $input: unique symbol = 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;

開発者が z.registry<{ examples: z.$output[] }>() を宣言し、.add(z.string(), { examples: ["hello"] }) を呼び出すと、$replace が配列・関数引数のタプル・ネストされたオブジェクトを再帰的に走査し、$output を core.output<ZodString>(すなわち string)へと厳密に置換します。

2. ガベージコレクション安全性 (_map: WeakMap)、_idmap、および親チェーン継承

$ZodRegistry は内部に 2 つのストアを保持します。

  1. _map: WeakMap<Schema, $replace<Meta, Schema>>: id を持たない一時的なスキーマは弱参照(WeakMap)で保持されます。スキーマがレキシカルスコープ外に出ると、V8 のガベージコレクタはスキーマインスタンスとメタデータエントリの双方をメモリリークなしに回収できます。
  2. _idmap: Map<string, Schema>: メタデータに "id" キーが含まれる場合(if (meta && typeof meta === "object" && "id" in meta))、_idmap.set(meta.id!, schema) が強参照を保持し、レジストリ全体を対象とした z.toJSONSchema(myRegistry) が名前付きスキーマを列挙できるようにします。
  3. 親チェーンのメタデータ継承 (get(schema)): .describe() や .optional() などのクローンメソッドは clone._zod.parent = inst を設定します。registry.get(schema) は schema._zod.parent を遡って親のメタデータをマージ({ ...pm, ...this._map.get(schema) })しますが、その際に delete pm.id; // do not inherit id を実行して id のみを除外し、派生スキーマが親スキーマの $defs 識別子と衝突するのを防ぎます。
  4. デュアルパッケージハザードの防止 (globalThis.__zod_globalRegistry): globalRegistry シングルトンは globalThis.__zod_globalRegistry にアンカーされており、同一ランタイム内で CommonJS(require("zod"))と ESM(import "zod")の双方がロードされた場合でも単一のグローバルレジストリを共有します。

5.2 順方向合成: toJSONSchema と json-schema-processors.ts

関数 API の toJSONSchema(schemaOrRegistry, params) およびレガシー互換クラス JSONSchemaGenerator(packages/zod/src/v4/core/json-schema-generator.ts)は、どちらも ToJSONSchemaContext 上で 3 パスのグラフアルゴリズムを実行します。

Pass 1: processSchema() と論理積チェック畳み込み (aggregateChecks)

packages/zod/src/v4/core/to-json-schema.ts の processSchema(schema, ctx, params)(バンドラの const process ポリフィルとの名前衝突を避けるため process から改名)は、深さ優先探索を実行します。

  1. ctx.seen による参照カウントと循環検出: 訪問したすべての $ZodType は ctx.seen: Map<$ZodType, Seen> に記録されます。再訪問時には seen.count++ がインクリメントされ、params.schemaPath.includes(schema) が真であれば seen.cycle = params.path によって再帰サイクルとしてマークされます。
  2. 順序非依存のチェック集約 (json-schema-processors.ts の aggregateChecks): onattach コールバックの実行順序に依存する _zod.bag を直接読むのではなく、aggregateChecks(schema) は def.checks を論理積(conjunction)として畳み込みます。
    • narrowMin / narrowMax: 数値境界および長さに最も厳しい境界値を採用します(最小値は value > agg[key]、最大値は value < agg[key])。
    • addDivisor: すべての multiple_of 除数を収集し、最初の除数を json.multipleOf に配置、2 個目以降を json.allOf に出力します。
    • addPattern: すべての RegExp パターンを収集し、複数のパターンがある場合は json.allOf に出力します(レガシードラフトでは { type: "string", pattern } を付与)。
    • intersectMime: 複数の .mime() チェック間で許可された MIME タイプ配列の積集合(agg.mime.filter(m => mime.includes(m)))を計算します。
  3. 静的なオプショナル性の解決 (inputOptin): ctx.io === "input" の際、objectProcessor と tupleProcessor は inputOptin(field) を呼び出し、実行時に欠損キーを観測する目的だけで optin = "optional" を設定するランタイムラッパー($ZodPipe の transform や $ZodCatch)を透過して、本来の静的な required 契約を復元します。

Pass 2: extractDefs() と RFC 6901 JSON Pointer エスケープ

Pass 1 が ctx.seen を構築した後、extractDefs(ctx, schema) が全エントリをスキャンします。

  • まず、異なる 2 つのスキーマが同一の .meta({ id }) 文字列を共有していないかを検証し、重複があればエラーをスローします。
  • 次に、ctx.cycles === "throw" の場合、seen.cycle を持つエントリがあれば循環パスを含めたエラーを送出します。
  • それ以外の場合、extractToDef(entry) は以下のいずれかに該当するスキーマを抽出します:
    1. ctx.external.registry に id 付きで登録されているスキーマ
    2. ctx.metadataRegistry に明示的な id を持つスキーマ
    3. サイクルに参加しているスキーマ(seen.cycle が定義済み)
    4. ctx.reused === "ref" かつ複数回参照されたスキーマ(seen.count > 1)
  • 抽出されたスキーマは本体を seen.def に退避し、元の seen.schema をインプレースで { $ref: "#/..." } に書き換えます。この際、encodeJSONPointerSegment によって RFC 6901 エスケープ(~ $\rightarrow$ ~0、/ $\rightarrow$ ~1)が適用され、#/$defs/(draft-2020-12)または #/definitions/(draft-07、draft-04、openapi-3.0)配下に配置されます。

Pass 3: finalize() — 参照の平坦化・Union 短縮・Intersection 畳み込み

finalize(ctx, schema) は最終出力を返す前に 4 つのポストプロセスを実行します。

  1. 逆順の flattenRef(zodSchema): ラッパースキーマ(optional、readonly、default、prefault、catch、pipe、lazy)は Pass 1 で seen.ref = def.innerType を記録します。flattenRef は ctx.seen を逆順に走査し、seen.ref および _zod.parent から継承したプロパティをマージします。$ref と兄弟キーワードの共存を禁止するレガシーターゲット(draft-07、draft-04、openapi-3.0)では、$ref を schema.allOf = [{ $ref }] の内部にラップします。
  2. compactTypeUnion(schema): OpenAPI 3.0 以外のターゲットにおいて、anyOf: [{ type: "string" }, { type: "null" }] のような単純な型ユニオンをコンパクトな配列形式 type: ["string", "null"] に短縮します。
  3. ctx.deferred と rewriteKeyNames(ctx): JSON オブジェクトのキーは常に文字列であるため、数値キーのレコード z.record(z.number(), V) の propertyNames スキーマを数値正規表現パターン(regexes.number / regexes.integer)付きの文字列スキーマへと書き換えます。
  4. 構造的 Intersection 畳み込み (foldIntersection): JSON Schema において、閉じられた 2 つのオブジェクト(additionalProperties: false)を単純に allOf で結合すると、互いの固有プロパティを拒否し合って充足不能なスキーマになります。foldIntersection() は畳み込み可能なオブジェクト群の properties、required 集合、および additionalProperties / catchall 制約を統合し(さらに単一の anyOf/oneOf ユニオンに対しては分配法則 $A \land (X \lor Y) \Rightarrow (A \land X) \lor (A \land Y)$ を適用)、単一のオブジェクトスキーマへと合成します。
  5. Standard JSON Schema の付与 (~standard.jsonSchema): 最後に JSON.parse(JSON.stringify(result)) でディープクローンを作成し、StandardJSONSchemaV1 を実装する列挙不可プロパティ ~standard をアタッチします。

5.3 逆コンパイル: fromJSONSchema (packages/zod/src/v4/classic/from-json-schema.ts)

逆コンパイラ fromJSONSchema(schema, params)(packages/zod/src/v4/classic/from-json-schema.ts)は、draft-2020-12、draft-7、draft-4、openapi-3.0 の JSON Schema ドキュメントから実行時の ZodType AST を再構築します。

05_registries_and_json_schema_2
図 5.2: fromJSONSchema における逆コンパイルパイプライン (from-json-schema.ts)

1. 入力のサニタイズとローカル $ref 循環解決 (resolveRef)

  1. JSON 正規化ゲート: fromJSONSchema は最初に入力スキーマを JSON.parse(JSON.stringify(schema)) に通し、JavaScript の参照循環、getter、Proxy トラップを含まない有限なプレーンオブジェクトグラフを保証します(文字通りの "__proto__" プロパティも無害なデータプロパティに変換されます)。
  2. RFC 6901 デコードと ctx.processing による循環切断:
    • ローカル $ref 文字列(# または #/$defs/<key> / #/definitions/<key>)は decodeJSONPointerSegment(segment)(encodeJSONPointerSegment の逆変換として ~0 $\rightarrow$ ~ の前に ~1 $\rightarrow$ / を復元)でデコードされます。
    • メモ化されたターゲットは ctx.refs: Map<string, ZodType> に保持されます。$ref に遭遇した時点で ctx.processing.has(refPath) が真であれば、コンパイラは再帰サイクルを検出し、即座に z.lazy(() => ctx.refs.get(refPath)!) を返して無限再帰を断ち切ります。

2. パイプ前の生入力インスタンスガード (checkObjectGuards & checkArrayGuards)

JSON Schema と Zod の間の微妙な意味論的差異として、Zod の z.object() はパース時に未知のキーをストリップ(または .default() プロパティを補完)し、z.array() は要素のデフォルト値を補完します。一方、JSON Schema の minProperties、maxProperties、propertyNames、uniqueItems、contains(minContains / maxContains)はパース前の生の入力インスタンスに対して定義されています。

内部のデフォルト補完やキーのストリップが値を変更する前にこれらのキーワードを強制しつつ、後続の toJSONSchema() 呼び出しが input と output の両方で内部のオブジェクト・配列型を認識できるようにするため、fromJSONSchema は恒等変換パイプ(z.transform((value: unknown) => value).check(...).pipe(innerSchema))を前置します。

  • checkObjectGuards(objectSchema, guards): objectSchema へパイプする前に、生オブジェクトの Object.getOwnPropertyNames(value) を検査して minProperties、maxProperties、および keySchema.safeParse(key)(失敗時は invalid_key イシューを発行)を検証します。
  • checkArrayGuards(arraySchema, guards):
    • uniqueItems: true の場合、canonicalKey(item, new Set())(オブジェクトのキーをソートし、文字列やキーに ${length}: を前置する単射な長さプレフィックス付き直列化)を計算し、$\mathcal{O}(N^2)$ の全ペア深層比較ではなく Map<string, number> ルックアップによる $\mathcal{O}(N)$ 時間での重複検出を実行します。
    • contains の場合、containsSchema.safeParse(item).success を満たす要素数をカウントし、maxContains + 1 に達した時点で早期終了します。

3. 合成レイヤリングとロスレスなレジストリ往復

convertBaseSchema(schema, ctx) が基本の ZodType を構築した後、convertSchema は厳密な順序で合成キーワードとメタデータキーワードをレイヤリングします。

  1. anyOf $\rightarrow$ z.union(...) および oneOf $\rightarrow$ z.xor(...): スキーマが明示的な type、enum、または const も宣言している場合、ユニオンはベース制約と交差(z.intersection(baseSchema, union))されます。
  2. allOf $\rightarrow$ チェーンされた z.intersection(...)。
  3. OpenAPI nullable: true $\rightarrow$ z.nullable(baseSchema)、readOnly: true $\rightarrow$ z.readonly(baseSchema)、および default $\rightarrow$ baseSchema.default(schema.default)。
  4. メタデータの保持: コア JSON Schema アノテーション($id、id、$comment、contentEncoding など)、ガードキーワード($ref を含まない場合の propertyNames、minProperties、uniqueItems、contains)、および未認識のカスタムキー(!RECOGNIZED_KEYS.has(key))は、最後に .describe(schema.description) を適用する前に ctx.registry.add(baseSchema, extraMeta) へ登録されます。

← 4. Doc コード生成と JIT スキーマコンパイラ (compile.ts) | 6. 型レベル推論のスケーリング・エラー解決・49 ロケール →