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全体をゼロアロケーションの検証クロージャへとインライン展開します。

04_jit_compiler_engine_1
図4.1: Zod v4 Docコード生成と2層JIT/AOTコンパイルパイプライン
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つの責務を担います。

  1. クロージャ環境の束縛 (closed: Record<string, unknown>): 生成される関数が参照するランタイムオブジェクト(shape 辞書、コンパイル済み RegExp、Set ルックアップテーブル、ヘルパー関数など)をソース文字列へシリアライズせずに名前付き束縛として保持します。
  2. 例外安全なインデント管理 (indented(fn)): try ... finally ブロック内で this.indent を増減します。compile.ts の compileChild() は、ネストされたブロックの途中で ZodCompileUnsupportedError が送出された際にそれを捕捉して doc.content.length を巻き戻すため、finally で確実に this.indent を復元することが後続のフォールバックコードの整合性に不可欠です。
  3. 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つの重要な不変条件が組み込まれています。

  1. Getterの1回評価キャッシュ: generateObjectCheck は各プロパティ key に対して必ず最初に const v0 = input["key"]; を出力し、以降の型チェック・制約チェック・出力オブジェクト構築ではすべて v0 を参照します。これにより、入力オブジェクトが副作用のあるGetterやProxyトラップを持っていても、検証時と出力構築時で異なる値が読み取られるTOCTOU(Time-of-Check to Time-of-Use)不整合を防止します。
  2. __proto__ プロトタイプ汚染ガード: generateObjectCheck および generateRecordCheck では、__proto__ キーに対して明示的なガード(if (k === "__proto__") continue; または ZodCompileUnsupportedError('object shape key "__proto__"'))を設け、生成されたオブジェクトリテラルやブラケット代入が Object.prototype.__proto__ セッターを誤発火させないことを保証しています。
  3. コードインジェクション防止と正規表現完全性リスト (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 に静的なスキーマ循環検出とランタイムのオブジェクトグラフメモ化を集約しています。

04_jit_compiler_engine_2
図4.2: 静的スキーマ循環束(Lattice)とランタイム後方参照(Back-Edge)メモ化プロトコル (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() }) でオプトイン可能です)。

  1. 非再帰コンテナのゼロコスト自動解除: コンテナ($ZodObject, $ZodArray, $ZodRecord, $ZodMap, $ZodSet, $ZodTuple)の初回パース時、memo.attach(inst) が isRecursive(inst, new Set(), false) を評価します。結果が NONE であれば、ラッパーは即座に inst._zod.parse = base および inst._zod.run = base に元の関数を書き戻して自身を完全にアンインストールします。これにより、非再帰コンテナの2回目以降のパースではメモ化のオーバーヘッドがゼロになります。
  2. 子要素パース前のプレースホルダ登録 (memo.alloc): 再帰コンテナが子要素をパースする直前に、モジュール変数 handoff に現在の (ctx["~memo"].buckets.get(inst)) WeakMap をセットし、memo.alloc(inst, payload, empty, ctx) を呼び出します。これにより空の出力コンテナ { value: empty, issues: null } が bucket に登録され、open スタックに積まれます。
  3. 後方参照検出 (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")を送出します。

← 3. ParsePayload & 検証エンジン | 5. レジストリとJSON Schema合成 →