6. 型レベル推論のスケーリング・エラー解決・49 ロケール

Zod v4 における最も影響力の大きいアーキテクチャ革新の 2 つは、ランタイムのパースループの外側で行われています。1 つ目は深くネストされたスキーマにおける TypeScript コンパイラ (tsc) の型インスタンス化数の劇的な削減(packages/zod/src/v4/core/core.ts、schemas.ts、standard-schema.ts)、2 つ目は軽量な $ZodRawIssue の遅延確定とモジュラーなロケール辞書(packages/zod/src/v4/core/errors.ts、parse.ts、util.ts、および packages/zod/src/v4/locales/)によるバリデーション経路上のエラーフォーマット負荷の排除です。

06_type_inference_and_locales_1
図 6.1: フラットな _zod 型内部表現と Zod v3 ジェネリクスの比較および Standard Schema v1 ブリッジ
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 Banner: Type Extraction & Standard Schema Pipeline
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="$ZodType<O, I, Internals>")
pipeline.add("2. _zod 型バッグ", description="_zod['output'] & _zod['input']", style=Styles.PrimaryNeutral)
pipeline.add(
    "3. z.infer / z.input",
    description="T['_zod']['output']",
    style=Styles.PrimaryFlat,
    text_style=Styles.WhiteBold.patch(text_size=11.0),
    description_style=Styles.White.patch(text_size=10.0),
)
pipeline.add("4. ~standard ブリッジ", description="StandardSchemaV1.Props", style=Styles.Neutral)
pipeline.draw(xy=(6, 67), width=152, height=14)

# Left Panel: tsc Instantiation Scaling Techniques
left_panel = Panel(
    "tsc スケーリング設計",
    "core.ts & schemas.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=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))

tsc_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,
)
tsc_cards.add(
    "フラットな ZodTypeInternals<O, I>",
    "T['_zod']['output'] の直接インデックス参照",
    badge_text="T1",
    badge_style=Styles.PrimaryFlat,
    badge_text_style=Styles.WhiteBold.patch(text_size=10.0),
)
tsc_cards.add(
    "optin / optout ファントムフラグ",
    "_zod.optin / optout による O(1) キー振り分け",
    badge_text="T2",
)
tsc_cards.add(
    "ラッパー不要の Brand / Narrow 型",
    "新規クラスなしで _zod.output を交差型合成",
    badge_text="T3",
)
tsc_cards.draw(l_xy, width=l_w, height=l_h)

# Right Panel: Zod v3 vs. Zod v4 Type Representation Comparison
right_panel = Panel(
    "v3 と v4 の型表現比較",
    "tsc Comparison",
    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.code_block,
    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")

comp_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,
)
comp_table.draw(
    xy=(88.5, 51.5),
    width=66.0,
    height=43.5,
    data=[
        ["比較項目", "Zod v3", "Zod v4"],
        ["Object 型引数", "クラスに 5 個", "2 個 (Shape, Cfg)"],
        ["Optional 判定", "isOptional() 再帰", "_zod.optin/out"],
        [".extend()", "MergeShapes<A,B>", "Shape & U"],
        [".brand()", "ZodBranded<T,B>", "T & _zod.output"],
    ],
)

save()

6.1 型レベル推論のスケーリング (core.ts, schemas.ts, standard-schema.ts)

Zod v3 では、ZodObject<T, UnknownKeys, Catchall, Output, Input> が5 つのジェネリック型パラメータを保持しており、Output = baseObjectOutputType<T> と Input = baseObjectInputType<T> がクラスシグネチャ自体の中で先行評価(eager evaluation)されていました。そのため、.extend()、.merge()、.pick()、.omit() をチェーンするたびに TypeScript コンパイラ(tsc)がすべての中間ステップでマップ型を再計算・再インスタンス化し、TS2589: Type instantiation is excessively deep and possibly infinite が頻発していました。

Zod v4 は packages/zod/src/v4/core/core.ts と packages/zod/src/v4/core/schemas.ts における 4 つの型システム設計によってこのボトルネックを解消しています。

1. フラットな _zod: $ZodTypeInternals<O, I> インデックス参照

Zod v4 のすべてのスキーマインスタンスは $ZodType<O, I, Internals> に準拠し、静的な入出力型やメタデータを深いクラスの型引数ではなく単一の _zod プロパティバッグ内に格納します。

// packages/zod/src/v4/core/core.ts
export interface $ZodTypeInternals<out O = unknown, out I = unknown> extends _$ZodTypeInternals {
  output: O;
  input: I;
}

export type input<T> = T extends { _zod: { input: any } } ? T["_zod"]["input"] : unknown;
export type output<T> = T extends { _zod: { output: any } } ? T["_zod"]["output"] : unknown;
export type { output as infer };

output<T>(z.infer<T>)および input<T>(z.input<T>)は、T extends ZodType<infer O, any, any> のような条件付き型パターンマッチを行わず、直接のインデックスアクセス(T["_zod"]["output"])を行うため、TypeScript は _zod 上のプロパティ型をそのままキャッシュできます。

2. 2 引数の $ZodObject<Shape, Config> と optin / optout ファントムフラグ

$ZodObject<Shape, Config> はクラス型引数として Shape と Config extends $ZodObjectConfig = $strip の 2 つだけを受け取り、オブジェクトの入出力マップ型の計算を $ZodObjectInternals<Shape, Config> まで遅延させます。

// packages/zod/src/v4/core/schemas.ts
type OptionalOutSchema = { _zod: { optout: "optional" } };
type OptionalInSchema = { _zod: { optin: "optional" } };

export type $InferObjectOutput<T extends $ZodLooseShape, Extra extends Record<string, unknown>> =
  string extends keyof T
    ? util.IsAny<T[keyof T]> extends true
      ? Record<string, any>
      : Record<string, never>
    : Identity<
        {
          -readonly [k in keyof T as T[k] extends OptionalOutSchema ? never : k]: T[k]["_zod"]["output"];
        } & {
          -readonly [k in keyof T as T[k] extends OptionalOutSchema ? k : never]?: T[k]["_zod"]["output"];
        } & Extra
      >;

$InferObjectOutput と $InferObjectInput における主な tsc 最適化は以下の通りです:

  • 空 Shape の高速パス (string extends keyof T): z.object({}) の場合にキー再マッピングのループを省略し、即座に Record<string, never> へと解決します。
  • optin / optout ファントムフラグ: $ZodOptional(optin: "optional"; optout: "optional")、$ZodDefault(optin: "optional"; optout?: undefined)、$ZodPrefault(optin: "optional")などのラッパーは、オプショナル性を _zod 直下に宣言します。これにより、T[k] extends OptionalOutSchema という $\mathcal{O}(1)$ の構造チェックだけで必須キーとオプショナルキーを分割できます。
  • util.Extend<Shape, U> によるフラットな交差型: .extend(U) は util.Extend<Shape, U>(keyof Shape & keyof U extends never ? Shape & U : Omit<Shape, keyof U> & U)を使用し、追加キーが既存キーと重複しない限り純粋な交差型 Shape & U を用いてマップ型の再構築を回避します。

3. ラッパークラスなしの型交差 ($ZodBranded と $ZodNarrow)

Zod v3 では .brand<"UserId">() を呼ぶたびに実行時に new ZodBranded(schema) ラッパークラスを生成していました。Zod v4 では、.brand() は実行時にはスキーマ自身をそのまま返し(return this as any)、型レベルでのみラッパーなしの交差型を適用します。

// packages/zod/src/v4/core/core.ts
export type $ZodBranded<T extends SomeType, Brand extends string | number | symbol> = T &
  Record<"_zod", Record<"output", output<T> & $brand<Brand>>>;

export type $ZodNarrow<T extends SomeType, Out> = T & Record<"_zod", Record<"output", Out>>;

4. ネイティブ Standard Schema v1 準拠 (standard-schema.ts の ~standard)

すべての $ZodType はコンストラクタ(packages/zod/src/v4/core/schemas.ts)内で util.defineLazy によって遅延定義された ~standard ゲッターを公開し、StandardSchemaV1 と StandardJSONSchemaV1 の双方を実装します。

// packages/zod/src/v4/core/schemas.ts
util.defineLazy(inst, "~standard", () => ({
  validate: (value: unknown) => {
    try {
      const r = safeParse(inst, value);
      return r.success ? { value: r.data } : { issues: r.error?.issues };
    } catch {
      return safeParseAsync(inst, value).then((r) =>
        r.success ? { value: r.data } : { issues: r.error?.issues }
      );
    }
  },
  vendor: "zod",
  version: 1 as const,
}));

validate(value) はまず同期的な safeParse(inst, value) を試行し、非同期の refinement や transform によって $ZodAsyncError がスローされた場合にのみ safeParseAsync(inst, value) にフォールバックします。


6.2 ゼロアロケーション Issue 収集・finalizeIssue・49+ ロケール

Zod v3 では、バリデーションが失敗するたびに完全な Error オブジェクトを生成(V8 のスタックトレース取得を発火)し、パースの途中でローカライズ文字列を先行フォーマットしていました。Zod v4 は生の Issue 収集 ($ZodRawIssue) とエラーの確定・フォーマット (finalizeIssue) を完全に分離しています。

06_type_inference_and_locales_2
図 6.2: $ZodRawIssue の遅延確定ウォーターフォール・V8 スタック取得バイパス・エラーフォーマッタ

1. スタック取得をバイパスする newError() と safeParse の遅延 error ゲッター

V8 において new Error() を呼び出すと、実行スタックを遡って error.stack を構築するため、バリデーション失敗時の CPU コストの大部分を占めます。Zod v4 は 2 つの仕組みでこのオーバーヘッドを取り除いています。

  1. newError における Error.stackTraceLimit = 0 (core.ts): newError(issues) 内で ZodRealError をインスタンス化する際、new Definition(issues) の前後で一時的に Error.stackTraceLimit = 0 を設定し、finally ブロックで元の値に戻します。例外をスローする parse() が失敗した場合のみ、Error.captureStackTrace(e, callee) を用いて呼び出し元を起点とするクリーンなスタックトレースを復元します。
  2. safeParse() における遅延 error ゲッター (parse.ts): safeParse() が失敗した際、即座に finalizeIssue や new ZodError() を実行するのではなく、メモ化された遅延ゲッター get error() を持つオブジェクトを返します。
// packages/zod/src/v4/core/parse.ts
return result.issues.length
  ? {
      success: false,
      get error() {
        if (this._error) return this._error as any;
        const issues = result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()));
        const err = new _Error(issues);
        this._error = err;
        return err;
      },
      _error: undefined,
    }
  : { success: true, data: result.value as any };

もし呼び出し側が if (!res.success) return null; のように成否のみを判定して res.error にアクセスしなければ、finalizeIssue も new _Error() も一切実行されません。さらに ZodError が生成された後も、err.message(JSON.stringify(err.issues, null, 2) をフォーマットする文字列)自体が packages/zod/src/v4/core/errors.ts の _messageDesc による遅延ゲッターとして実装されています。

2. finalizeIssue() の 6 段階エラーマップ優先順位ウォーターフォール (util.ts)

result.error が参照されると、util.finalizeIssue(iss, ctx, config)(packages/zod/src/v4/core/util.ts)が各 $ZodRawIssue をイミュータブルな公開 $ZodIssue へと変換します。

// packages/zod/src/v4/core/util.ts
export function finalizeIssue(iss: errors.$ZodRawIssue, ctx: ParseContextInternal | undefined, config: $ZodConfig): errors.$ZodIssue {
  const full = { ...iss, path: iss.path ?? [] } as errors.$ZodIssue;
  if (!iss.message) {
    const message =
      unwrapMessage(iss.inst?._zod.def?.error?.(iss as never)) ??
      unwrapMessage(iss.schema?._zod.def?.error?.(iss as never)) ??
      unwrapMessage(ctx?.error?.(iss as never)) ??
      unwrapMessage(config.customError?.(iss as never)) ??
      unwrapMessage(config.localeError?.(iss as never)) ??
      "Invalid input";
    full.message = message;
  }
  delete (full as any).inst;
  delete (full as any).continue;
  delete (full as any).schema;
  delete (full as any).__proto__;
  if (!ctx?.reportInput) {
    delete (full as any).input;
  }
  return full;
}

第 2 段階(iss.inst)と第 3 段階(iss.schema)の明確な役割分担に注目してください。

  • スキーマ(z.string({ error: "Schema err" }))に属するチェック(.min(5, { error: "Check err" }))が失敗した際、iss.inst は失敗した $ZodCheckMinLength インスタンスを指す一方、schemas.ts 内の util.attachSchema(schema) が親である $ZodString を iss.schema に刻印します。
  • これにより、チェック固有のエラーマップ(iss.inst._zod.def.error)が親スキーマのエラーマップ(iss.schema._zod.def.error)よりも優先され、その後ろにパース単位の ctx.error、グローバルな config.customError、ロケール別の config.localeError が続きます。
  • また、パスワードやトークンなどの機密入力がシリアライズされたエラーログに漏洩するのを防ぐため、ctx?.reportInput === true が明示されない限り finalizeIssue は full.input を削除します。

3. Tree-Shaking 対応の 49+ ロケールアーキテクチャ (packages/zod/src/v4/locales/)

packages/zod/src/v4/locales/index.ts では、49 以上の言語をカバーする 63 個のロケールモジュール(en、ja、ar、cs、de、es、fr、he、hi、id、it、ko、nl、pl、pt、ru、sv、th、tr、uk、vi、zhCN、zhTW など)がエクスポートされています。

各ロケールモジュールは外部依存ゼロのファクトリ関数(export default function (): { localeError: $ZodErrorMap })をエクスポートしており、3 つの軽量ヘルパーで構成されています。

  1. parsedType(data): 実行時の JavaScript 値をローカライズされた型名(en.ts では "number"・"NaN"・"array"、ja.ts では "数値"・"NaN"・"配列" など)に変換します。
  2. Nouns 辞書と getSizing(origin): issue.origin("string"、"array"、"set"、"file"、"number"、"int"、"bigint"、"date")を言語固有の単位名詞と動詞(en.ts では unit: "characters", verb: "to have"、ja.ts では unit: "文字", verb: "である")にマッピングします。
  3. issue.code の Switch ディスパッチ: Zod v4 で統合・整理された 11 種類の Issue コード(invalid_type、invalid_value、too_big、too_small、invalid_format、not_multiple_of、unrecognized_keys、invalid_key、invalid_union、invalid_element、custom)に応じて自然なエラー文を生成します。

zod/v4/core はデフォルトでどのロケールもインポートしないため(Classic と Mini のエントリポイントが初期化時に en() をロード)、zod/v4/core を直接利用するアプリケーションや import ja from "zod/v4/locales/ja.js" のように必要な言語のみをインポートする構成では、未使用言語のバンドルコストは完全にゼロになります。

4. 構造化エラーフォーマッタ (treeifyError, prettifyError, flattenError, formatError)

packages/zod/src/v4/core/errors.ts は $ZodError を構造化・整形する 4 つのユーティリティを提供します。

  • treeifyError(err, mapper): 従来の format() / flatten() を置き換える推奨 API であり、オブジェクトのプロパティパスと配列のインデックスパスを明確に分離した再帰ツリー { errors: string[], properties?: Record<string, $ZodErrorTree>, items?: ($ZodErrorTree | undefined)[] } を構築します。
  • prettifyError(err): Issue を issue.path.length の昇順にソートし、✖ ${issue.message} と → at ${toDotPath(issue.path)}(識別子以外のキーは ["..."] でエスケープ)の形式で人間が読みやすいサマリを生成します。ユニオンのネストされたサブエラーも再帰的にインデント表示します。
  • プロトタイプ汚染耐性を備えた flattenError と formatError: 両レガシーフォーマッタとも、動的キーを書き込む際に Object.prototype.hasOwnProperty チェックと Object.defineProperty を併用しており、"__proto__" や "constructor" といった悪意あるパスセグメントによって Object.prototype が汚染されるのを防いでいます。

← 5. WeakMap レジストリと JSON Schema 双方向エンジン | アーキテクチャ概要トップへ →