4. Doc コード生成とJITスキーマコンパイラ (compile.ts)
Zod v4は、軽量なJavaScriptソースコードビルダ(packages/zod/src/v4/core/doc.ts の Doc クラス)を基盤とする2層動的コード生成アーキテクチャによって、Zod v3比で桁違いの検証スループットを実現しています。第1層では、$ZodObjectJIT が初回同期パース時にオブジェクトのプロパティ走査ループを自動コンパイルします。第2層では、Ahead-of-Time(AOT)コンパイラ(packages/zod/src/v4/core/compile.ts)が循環検出エンジン(packages/zod/src/v4/core/memoizer.ts)と連携しながら、スキーマAST全体をゼロアロケーションの検証クロージャへとインライン展開します。
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, ChevronProcess, Panel
from drawlib.styles import Styles
setup(width=164, height=84)
# Top Pipeline: 4-Stage Compilation 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. 循環・CSP検査", description="isRecursiveSchema()")
pipeline.add("2. AST走査とDoc出力", description="generateCheck(doc, ctx)", style=Styles.PrimaryNeutral)
pipeline.add(
"3. クロージャ巻き上げ",
description="new Function(c0..cN)",
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="withParser(schema)")
pipeline.draw(xy=(6, 65), width=152, height=14)
# Bottom Left Panel: Two-Tier Codegen Architecture
left_panel = Panel(
"2層コード生成エンジン",
"doc.ts & compile.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.cpu,
icon_style=Styles.PrimaryBold,
header_variant="filled",
header_height=8.5,
)
left_panel.draw((6.0, 5.0), width=73.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box((6.0, 5.0), width=73.0, height=56.0, padding=(3.0, 2.5))
tier_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,
)
tier_cards.add(
"$ZodObjectJIT (schemas.ts)",
"shape, inst, memo, symsをクロージャ束縛",
badge_text="T1",
)
tier_cards.add(
"z.compile() AOT (compile.ts)",
"AST全体をインライン化し出力値またはINVALID返却",
badge_text="T2",
badge_style=Styles.PrimaryFlat,
badge_text_style=Styles.WhiteBold.patch(text_size=10.0),
)
tier_cards.add(
"Assert専用バリデータ分離",
"assertOnly: trueで出力オブジェクト生成を省略",
badge_text="V8",
)
tier_cards.draw(l_xy, width=l_w, height=l_h)
# Bottom Right Panel: Fallback & Degradation Boundaries
right_panel = Panel(
"フォールバック機構",
"アイランド分離",
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.shield_check,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=8.5,
)
right_panel.draw((85.0, 5.0), width=73.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box((85.0, 5.0), width=73.0, height=56.0, padding=(3.0, 2.5))
fallback_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.SecondaryNeutral,
badge_text_style=Styles.DarkBold.patch(text_size=10.0),
spacing=2.2,
)
fallback_cards.add(
"部分木ランタイム・アイランド",
"DocをロールバックしruntimeRun()で部分実行",
badge_text="F1",
)
fallback_cards.add(
"単一パス FALLBACK_FLAG",
"INVALID時にctx[FALLBACK_FLAG]で再実行を抑止",
badge_text="F2",
)
fallback_cards.add(
"CSP・jitless安全バイパス",
"allowsEval判定とtry/catchで解釈実行へ委譲",
badge_text="F3",
)
fallback_cards.draw(r_xy, width=r_w, height=r_h)
save()
4.1 Doc コードビルダ (packages/zod/src/v4/core/doc.ts)
$ZodObjectJIT と z.compile() はどちらも、packages/zod/src/v4/core/doc.ts の Doc クラスを用いてJavaScriptソースコードを組み立てます。外部依存ゼロ・約50行で実装された Doc クラスは、以下の3つの責務を担います。
- クロージャ環境の束縛 (
closed: Record<string, unknown>): 生成される関数が参照するランタイムオブジェクト(shape辞書、コンパイル済みRegExp、Setルックアップテーブル、ヘルパー関数など)をソース文字列へシリアライズせずに名前付き束縛として保持します。 - 例外安全なインデント管理 (
indented(fn)):try ... finallyブロック内でthis.indentを増減します。compile.tsのcompileChild()は、ネストされたブロックの途中でZodCompileUnsupportedErrorが送出された際にそれを捕捉してdoc.content.lengthを巻き戻すため、finallyで確実にthis.indentを復元することが後続のフォールバックコードの整合性に不可欠です。 - 2段ファクトリによる関数生成 (
compile()): ローカルスコープでeval()を呼ぶのではなく、new Function(...Object.keys(this.closed), "return function (" + this.args.join(", ") + ") {\n" + content.join("\n") + "\n};")という外側ファクトリ関数を構築し、即座にfactory(...Object.values(this.closed))を実行して内部クロージャを返します。
// packages/zod/src/v4/core/doc.ts
export class Doc {
args: string[];
/** Bindings the compiled function closes over, by name. */
closed: Record<string, unknown>;
content: string[] = [];
indent = 0;
constructor(args: string[] = [], closed: Record<string, unknown> = {}) {
this.args = args;
this.closed = closed;
}
// the compiler catches a child's throw and keeps writing into this doc, so the indent has to unwind with it
indented(fn: (doc: Doc) => void) {
this.indent += 1;
try {
fn(this);
} finally {
this.indent -= 1;
}
}
compile(): any {
const F = Function;
const content = this?.content ?? [``];
const factory = new F(
...Object.keys(this.closed),
`return function (${this.args.join(", ")}) {\n${content.join("\n")}\n};`
);
return factory(...Object.values(this.closed)) as any;
}
}
なぜ shape を引数渡しではなくクロージャ束縛するとV8 TurboFanで高速化されるのか
$ZodObjectJIT(packages/zod/src/v4/core/schemas.ts)では、generateFastpass(def.shape) が new Doc(["payload", "ctx"], { shape, inst, memo, syms }) を初期化します。外側の new Function ファクトリ経由で shape をクロージャ変数として束縛すると、V8の最適化コンパイラ(Sparkplug / Maglev / TurboFan)はその特定の shape オブジェクト参照に対して内部関数を特殊化(Specialize)し、各プロパティの shape["email"]._zod.run(...) 呼び出しを既知の関数ターゲット(Known Callee)へと脱仮想化(Devirtualize)します。これにより、shape を引数として渡す場合と比較して約13%高速に動作します。
4.2 第1層 ($ZodObjectJIT) と第2層 (z.compile()) の比較
Zod v4では、ランタイム時のオブジェクトループ展開と、スキーマ全体のAhead-of-Time(AOT)コンパイルを明確に分離しています。
| 比較軸 | 第1層: $ZodObjectJIT (schemas.ts) |
第2層: z.compile() AOT (compile.ts) |
|---|---|---|
| 発火タイミング | !globalConfig.jitless && allowsEval.value のとき初回同期パース時に遅延生成 |
明示的な z.compile(schema) または import "zod/compile"(postProcessor フック) |
| コンパイル対象範囲 | 単一 $ZodObject のプロパティ走査を展開し、子要素は shape[k]._zod.run() を呼ぶ |
$ZodType のAST全体を再帰的に単一のフラットなJS関数へとインライン展開 |
| エラー処理プロトコル | 失敗時にその場で payload.issues に追加し iss.path にキーを前置 |
INVALID シンボル(Symbol.for("zod.compile.invalid"))を返し、エラー構築はランタイムパーサへ委譲 |
| 循環参照サポート | globalConfig.memoizer 有効時に memo.alloc(inst, payload, {}, ctx) を呼び出し |
isRecursiveSchema(schema) により再帰スキーマを事前判定してAOT対象から除外 |
| バリデータ専用生成 | 標準の ParsePayload パスを使用 |
z.validate() 向けに出力構築を省いた assertOnly: true 版(_zod.bag.validator)を別途生成 |
4.3 compile.ts の内部機構 (compileFn, withParser, アイランド分離)
1. INVALID センチネルと2フェーズ実行 (withParser)
生成コード内でエラーパスやカスタムエラーマップを解決して $ZodRawIssue オブジェクトを組み立てようとすると、生成コードが肥大化してV8のインライン展開が阻害されます。そのため compileFn() は (input: unknown) => T | INVALID という純粋なファストパス関数のみを生成します。
検証成功時(out !== INVALID)には、ParsePayload や issues 配列を一切アロケートせずに構築済みの出力値を即座に返します。検証失敗時(out === INVALID)にのみ、withParser() が元のランタイムパーサ(originalRun(payload, ctx))を呼び出して正確なエラー情報を収集します。
// packages/zod/src/v4/core/compile.ts
export const INVALID: unique symbol = Symbol.for("zod.compile.invalid");
const FALLBACK_FLAG: unique symbol = Symbol.for("zod.compile.fallback");
export function withParser<T extends SomeType>(schema: T, parser: (input: unknown) => core.output<T> | INVALID): T {
if (isRecursiveSchema(schema as any)) {
throw new ZodCompileUnsupportedError("a schema whose subtree contains a reference cycle");
}
const clone = util.clone(schema as any) as T;
const liveRun = schema._zod.run as ((p: ParsePayload, c: ParseContextInternal) => any) & {
__originalRun?: (p: ParsePayload, c: ParseContextInternal) => any;
};
const originalRun = liveRun.__originalRun ?? liveRun;
const wrapped = (payload: ParsePayload, ctx: ParseContextInternal): any => {
if (
ctx?.async ||
ctx?.direction === "backward" ||
ctx?.skipChecks ||
(ctx as Record<symbol, unknown> | undefined)?.[FALLBACK_FLAG]
) {
return originalRun(payload, ctx);
}
if (ctx && isBackEdge(ctx, payload.value)) {
return originalRun(payload, ctx);
}
const out = parser(payload.value);
if (out !== INVALID) {
payload.value = out;
return payload;
}
if (ctx) (ctx as Record<symbol, unknown>)[FALLBACK_FLAG] = true;
return originalRun(payload, ctx);
};
(wrapped as { __originalRun?: typeof originalRun }).__originalRun = originalRun;
clone._zod.bag.fallbackRun = originalRun;
clone._zod.bag.validator = parser as CompiledFn<unknown>;
clone._zod.run = wrapped;
if (!liveRun.__originalRun) installCompiledUserMethods(clone, schema, parser as CompiledFn<core.output<T>>);
return clone;
}
[!IMPORTANT] グローバルコンパイルモードにおける
FALLBACK_FLAGの重要性:import "zod/compile"によってglobalConfig.postProcessorが全スキーマにコンパイル済みラッパーを装着している場合、親スキーマだけでなく各子プロパティのスキーマもファストパスラッパーを持ちます。親のファストパスがINVALIDを返してoriginalRun(payload, ctx)にフォールバックした際、ctx[FALLBACK_FLAG] = trueをセットすることで、ネストされた子スキーマが自身のファストパスを再実行するのを防ぎます。これにより、不正な入力時にもユーザー定義の.refine()や.transform()コールバックの実行回数が最大2回(ファストパス1回+エラー収集フォールバック1回)に厳密に制限されます。
2. 部分木ロールバックとランタイム・アイランド (compileChild & emitRuntimeIsland)
すべてのZod機能がインラインJSへ変換できるわけではありません(例:z.coerce.string()、排他的ユニオン z.xor()、カスタム when 条件付きチェックなど)。親の z.object() 内の1フィールドだけが非対応機能を使っている場合にオブジェクト全体のAOTコンパイルを諦めるのではなく、compileChild() は再帰呼び出しの前に Doc と CompileContext のカーソル位置をスナップショットします。
// packages/zod/src/v4/core/compile.ts
function compileChild(
doc: Doc,
ctx: CompileContext,
schema: SomeType,
accessor: string,
needsValue = true
): string | null {
const contentLen = doc.content.length;
const constantCount = ctx.constants.size;
const constantCounter = ctx.constantCounter;
const varCounter = ctx.varCounter;
try {
return generateCheck(doc, ctx, schema, accessor, needsValue);
} catch (err) {
if (!(err instanceof ZodCompileUnsupportedError) || !err.islandable) throw err;
doc.content.length = contentLen;
if (ctx.constants.size > constantCount) {
const trailing = Array.from(ctx.constants.keys()).slice(constantCount);
for (const k of trailing) ctx.constants.delete(k);
}
ctx.constantCounter = constantCounter;
ctx.varCounter = varCounter;
return emitRuntimeIsland(doc, ctx, schema, accessor);
}
}
ZodCompileUnsupportedError の islandable フラグは以下のように分岐します。
islandable = true(デフォルト): 該当する子スキーマのみをemitRuntimeIsland()によってruntimeRun(schema, value)のブラックボックス呼び出し(ランタイム・アイランド)へと置き換え、親コンテナは引き続き高速なコンパイル済みコードとして動作します。islandable = false: 定数以外のコールバックを持つz.catch(callback)(CONSTANT_CATCHタグがないもの)で使用されます。catchコールバックはctx.errorを参照してエラーメッセージを読み取った上で成功扱いで値を返すため、パースコンテキストを持たないアイランド内で実行すると誤ったエラーマップで確定したメッセージがそのまま成功値に混入してしまいます。そのためislandable: falseはルートまで例外を伝播させ、スキーマ全体をランタイム実行に維持します。ZodCompileAsyncError: 非同期の.refine()や.transform()が検出された際に送出され、compileChildで捕捉されずにスキーマ全体を非同期対応ランタイムへ委譲します。
3. プロパティ1回読み取り保証・プロトタイプ汚染防御・フォーマット許可リスト
generateObjectCheck と generateStringFormatCheck には、セキュリティとランタイム互換性を守る3つの重要な不変条件が組み込まれています。
- Getterの1回評価キャッシュ:
generateObjectCheckは各プロパティkeyに対して必ず最初にconst v0 = input["key"];を出力し、以降の型チェック・制約チェック・出力オブジェクト構築ではすべてv0を参照します。これにより、入力オブジェクトが副作用のあるGetterやProxyトラップを持っていても、検証時と出力構築時で異なる値が読み取られるTOCTOU(Time-of-Check to Time-of-Use)不整合を防止します。 __proto__プロトタイプ汚染ガード:generateObjectCheckおよびgenerateRecordCheckでは、__proto__キーに対して明示的なガード(if (k === "__proto__") continue;またはZodCompileUnsupportedError('object shape key "__proto__"'))を設け、生成されたオブジェクトリテラルやブラケット代入がObject.prototype.__proto__セッターを誤発火させないことを保証しています。- コードインジェクション防止と正規表現完全性リスト (
PATTERN_IS_COMPLETE):- 長さや要素数の境界値(
min_length,max_length,min_size)はnumericOperand(value, label)を通し、typeof value === "number" && Number.isFinite(value)を満たさない限りソースコード文字列に埋め込まれません。 generateStringFormatCheckは、def.patternの正規表現のみで検証が完結するフォーマット(uuid,email,ipv4,cuid,ulidなど)を明示的な許可リストPATTERN_IS_COMPLETEで管理しています。正規表現に加えてアルゴリズム検証が必要なフォーマット(base64のisValidBase64、ipv6のisValidIPv6、jwtのisValidJWT、credit_cardのLuhnアルゴリズムisValidCreditCard、ibanのMod-97検証isValidIBAN、urlのvalidateURL)は、addConstant()でランタイム検証関数そのものをクロージャ定数へ巻き上げて呼び出します。
- 長さや要素数の境界値(
4.4 再帰スキーマ循環検出とランタイムメモ化 (memoizer.ts)
コンパイル済みのファストパス関数はパースコンテキスト ctx(ParseContextInternal)を受け取らないため、再帰的なオブジェクトグラフの循環参照をファストパス内で解決することはできません。Zod v4では、packages/zod/src/v4/core/memoizer.ts に静的なスキーマ循環検出とランタイムのオブジェクトグラフメモ化を集約しています。
1. 3値確実性ラティス (NONE, ASSUMED, PROVEN) による静的循環判定
$ZodLazy のゲッターやオブジェクト shape 上のゲッターは任意のユーザーコードを実行し得るため、スキーマグラフに循環が含まれるかどうかの判定は単純なDFSでは済みません。memoizer.ts の isRecursive(inst, stack, resolve) は、3段階の確実性束(Lattice)を用いて走査を行います。
NONE (0): 到達可能なすべての子スキーマがリーフまたは非循環と証明された状態。モジュールレベルのrecursive: WeakMap<object, boolean>にfalseとしてキャッシュされます。ASSUMED (1): 未解決の$ZodLazy(resolve === false時)やオブジェクトshape上のプロパティゲッター(desc.get)に遭遇した状態。次回パース時にゲッターが解決されると非循環と判明する可能性があるため、settle()はASSUMEDをWeakMapにキャッシュしません。PROVEN (2): 現在の走査スタックstack: Set<object>に既に含まれている祖先スキーマへ再突入した状態。WeakMapにtrueとして永続キャッシュされます。
z.compile() が呼び出す isRecursiveSchema(inst) は resolve = true を渡して $ZodLazy の1ホップ先まで展開し、isRecursive(...) !== NONE のスキーマをコンパイル対象から安全に除外します。
2. 自己解除型コンテナラッパーと $ZodCyclicError
クラシック版 zod ではデフォルトで globalConfig.memoizer が有効化されています(一方、zod/mini ではバンドルサイズ削減のためデフォルト無効であり、z.config({ memoizer: z.memoizer() }) でオプトイン可能です)。
- 非再帰コンテナのゼロコスト自動解除: コンテナ(
$ZodObject,$ZodArray,$ZodRecord,$ZodMap,$ZodSet,$ZodTuple)の初回パース時、memo.attach(inst)がisRecursive(inst, new Set(), false)を評価します。結果がNONEであれば、ラッパーは即座にinst._zod.parse = baseおよびinst._zod.run = baseに元の関数を書き戻して自身を完全にアンインストールします。これにより、非再帰コンテナの2回目以降のパースではメモ化のオーバーヘッドがゼロになります。 - 子要素パース前のプレースホルダ登録 (
memo.alloc): 再帰コンテナが子要素をパースする直前に、モジュール変数handoffに現在の(ctx["~memo"].buckets.get(inst))WeakMapをセットし、memo.alloc(inst, payload, empty, ctx)を呼び出します。これにより空の出力コンテナ{ value: empty, issues: null }がbucketに登録され、openスタックに積まれます。 - 後方参照検出 (
isBackEdge) とTransformガード (memo.guard):- 子孫要素が同じ
(inst, input)ペアに再突入した際、まだ親コンテナのパースが完了しておらずhit.issues === nullであれば、memo.attachはstate.backEdges.add(hit.value)に後方参照を記録し、payload.memo = trueを立てて未完了プレースホルダhit.valueをそのまま返却することで循環参照を閉じます。 - しかし、その循環が単方向の
$ZodTransformや$ZodPipeを経由している場合、後方参照がプレースホルダを束縛する時点では変換後の出力値がまだ存在しません。そのためmemo.guard(inst)は変換スキーマをラップし、if (ctx.direction !== "backward" && isBackEdge(ctx, payload.value)) throw new $ZodCyclicError();を検査して決定論的にZodCyclicError("Cannot parse a reference cycle that closes through a transform")を送出します。
- 子孫要素が同じ