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)と連携させます。
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 つのストアを保持します。
_map: WeakMap<Schema, $replace<Meta, Schema>>:idを持たない一時的なスキーマは弱参照(WeakMap)で保持されます。スキーマがレキシカルスコープ外に出ると、V8 のガベージコレクタはスキーマインスタンスとメタデータエントリの双方をメモリリークなしに回収できます。_idmap: Map<string, Schema>: メタデータに"id"キーが含まれる場合(if (meta && typeof meta === "object" && "id" in meta))、_idmap.set(meta.id!, schema)が強参照を保持し、レジストリ全体を対象としたz.toJSONSchema(myRegistry)が名前付きスキーマを列挙できるようにします。- 親チェーンのメタデータ継承 (
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識別子と衝突するのを防ぎます。 - デュアルパッケージハザードの防止 (
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 から改名)は、深さ優先探索を実行します。
ctx.seenによる参照カウントと循環検出: 訪問したすべての$ZodTypeはctx.seen: Map<$ZodType, Seen>に記録されます。再訪問時にはseen.count++がインクリメントされ、params.schemaPath.includes(schema)が真であればseen.cycle = params.pathによって再帰サイクルとしてマークされます。- 順序非依存のチェック集約 (
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)))を計算します。
- 静的なオプショナル性の解決 (
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)は以下のいずれかに該当するスキーマを抽出します:ctx.external.registryにid付きで登録されているスキーマctx.metadataRegistryに明示的なidを持つスキーマ- サイクルに参加しているスキーマ(
seen.cycleが定義済み) 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 つのポストプロセスを実行します。
- 逆順の
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 }]の内部にラップします。 compactTypeUnion(schema): OpenAPI 3.0 以外のターゲットにおいて、anyOf: [{ type: "string" }, { type: "null" }]のような単純な型ユニオンをコンパクトな配列形式type: ["string", "null"]に短縮します。ctx.deferredとrewriteKeyNames(ctx): JSON オブジェクトのキーは常に文字列であるため、数値キーのレコードz.record(z.number(), V)のpropertyNamesスキーマを数値正規表現パターン(regexes.number/regexes.integer)付きの文字列スキーマへと書き換えます。- 構造的 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)$ を適用)、単一のオブジェクトスキーマへと合成します。 - 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 を再構築します。
1. 入力のサニタイズとローカル $ref 循環解決 (resolveRef)
- JSON 正規化ゲート:
fromJSONSchemaは最初に入力スキーマをJSON.parse(JSON.stringify(schema))に通し、JavaScript の参照循環、getter、Proxy トラップを含まない有限なプレーンオブジェクトグラフを保証します(文字通りの"__proto__"プロパティも無害なデータプロパティに変換されます)。 - 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 は厳密な順序で合成キーワードとメタデータキーワードをレイヤリングします。
anyOf$\rightarrow$z.union(...)およびoneOf$\rightarrow$z.xor(...): スキーマが明示的なtype、enum、またはconstも宣言している場合、ユニオンはベース制約と交差(z.intersection(baseSchema, union))されます。allOf$\rightarrow$ チェーンされたz.intersection(...)。- OpenAPI
nullable: true$\rightarrow$z.nullable(baseSchema)、readOnly: true$\rightarrow$z.readonly(baseSchema)、およびdefault$\rightarrow$baseSchema.default(schema.default)。 - メタデータの保持: コア 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 ロケール →