dependency-management
リポジトリ: colinhacks/zod 分析日: 2026-02-19
概要
Zod は TypeScript ファーストのスキーマバリデーションライブラリで、ゼロ外部ランタイム依存を貫きながら、再帰的スキーマ定義が本質的に生み出す循環参照問題を defineLazy / cached / セルフキャッシュ getter という 3 層の遅延評価パターンで解決している。さらに CI で madge --circular による静的循環依存チェックを自動化し、import type の徹底によりモジュール境界を型レベルとランタイムレベルで明確に分離している。ゼロ依存・循環回避・tree-shaking 対応の 3 つの制約を同時に満たす設計は、ライブラリ開発における依存管理の模範例となる。
背景にある原則
遅延評価で循環を断ち切る: モジュール間の循環依存は「初期化順序」が問題になる。プロパティアクセスを
Object.definePropertyで遅延化すれば、定義時の初期化順序に依存せず実行時に解決できる。Zod はdefineLazy(循環検出付き遅延プロパティ)、cached(一度だけ評価されるアクセサ)、セルフキャッシュ getter(初回アクセスで自分を上書き)の 3 パターンを使い分けている。- 根拠:
packages/zod/src/v4/core/util.ts:266-289(defineLazy)、util.ts:223-235(cached)、util.ts:613(assignProp セルフキャッシュ)
- 根拠:
import typeで依存グラフを削減する: TypeScript のimport typeはコンパイル後に消えるため、ランタイムの循環依存を生まない。Zod のv4/coreモジュール群では、型のみが必要な場合は一貫してimport typeを使い、ランタイム import を最小限に抑えている。- 根拠:
core/util.ts:1-4では schemas, errors, checks, core をすべてimport typeで参照。core/errors.ts:1-4、core/config.ts:1も同様。
- 根拠:
静的解析で循環を防御する: コードレビューだけでは循環依存の混入を防ぎきれない。CI に
madge --circularを組み込み、PR マージ前に自動検出する。既知の内部循環(v4/core内部)は--excludeで明示的に除外し、それ以外のモジュール間循環はゼロを強制する。- 根拠:
package.json:88のcheck:circularスクリプト、.github/workflows/test.yml:50-60
- 根拠:
外部依存ゼロをアーキテクチャレベルで強制する: Zod の
packages/zod/package.jsonにはdependenciesフィールドが存在しない。正規表現、日付パース、UUID 生成など通常は外部ライブラリに頼る機能もすべて内製している。これにより supply chain attack リスクの排除、bundle size の完全制御、破壊的変更への免疫が得られる。- 根拠:
packages/zod/package.jsonにdependenciesなし。core/regexes.tsで正規表現を自前実装。
- 根拠:
実例と分析
1. defineLazy — 循環検出付き遅延プロパティ
Zod のコアである defineLazy は、Object.defineProperty の getter/setter を使い、プロパティを遅延初期化する。特筆すべきは EVALUATING センチネルによる循環参照検出機構である。
// packages/zod/src/v4/core/util.ts:264-289
const EVALUATING = Symbol("evaluating");
export function defineLazy<T, K extends keyof T>(object: T, key: K, getter: () => T[K]): void {
let value: T[K] | typeof EVALUATING | undefined = undefined;
Object.defineProperty(object, key, {
get() {
if (value === EVALUATING) {
// Circular reference detected, return undefined to break the cycle
return undefined as T[K];
}
if (value === undefined) {
value = EVALUATING;
value = getter();
}
return value;
},
set(v) {
Object.defineProperty(object, key, {
value: v,
});
},
configurable: true,
});
}getter 評価中に同じプロパティが再度アクセスされた場合、EVALUATING 状態を検出して undefined を返すことで無限再帰を防ぐ。このパターンは $ZodLazy(再帰スキーマ)で特に重要で、innerType, pattern, propValues, optin, optout の 5 プロパティすべてに適用されている(schemas.ts:4412-4416)。
2. cached — 一度だけ評価されるアクセサ
// packages/zod/src/v4/core/util.ts:223-235
export function cached<T>(getter: () => T): { value: T; } {
const set = false;
return {
get value() {
if (!set) {
const value = getter();
Object.defineProperty(this, "value", { value });
return value;
}
throw new Error("cached value already set");
},
};
}cached は defineLazy と異なり、オブジェクトのプロパティではなく独立した値コンテナを返す。初回アクセス時に getter を評価し、Object.defineProperty で getter を実値に上書きする。allowsEval(util.ts:371)や $ZodObject の正規化処理(schemas.ts:1880)など、高コストな一回限りの計算に使われる。
3. セルフキャッシュ getter(assignProp(this, ...) パターン)
// packages/zod/src/v4/core/util.ts:602-615 (pick 関数内)
const def = mergeDefs(schema._zod.def, {
get shape() {
const newShape: Writeable<schemas.$ZodShape> = {};
for (const key in mask) {
if (!(key in currDef.shape)) {
throw new Error(`Unrecognized key: "${key}"`);
}
if (!mask[key]) continue;
newShape[key] = currDef.shape[key]!;
}
assignProp(this, "shape", newShape); // self-caching
return newShape;
},
checks: [],
});pick, omit, extend, merge, partial, required の 6 つのオブジェクト操作すべてで、shape プロパティに「初回アクセスで計算し、自分自身を実値で上書きする」getter が使われている。mergeDefs(util.ts:308-317)は Object.getOwnPropertyDescriptors を使うため、getter がそのまま保持される点がポイントである。
4. import type による依存グラフの制御
v4/core ディレクトリの import パターンを分析すると、明確なルールが見える。
| ファイル | import type | ランタイム import |
|---|---|---|
util.ts | checks, core, errors, schemas | なし |
errors.ts | checks, schemas, standard-schema | core, util |
config.ts | errors | なし |
core.ts | errors, schemas | util (Class のみ) |
checks.ts | errors, schemas | core, regexes, util |
schemas.ts | api, errors, json-schema, standard-schema, to-json-schema | checks, core, doc, parse, regexes, util, versions |
registries.ts | core, schemas | なし |
util.ts はランタイム import がゼロであり、他のすべてのモジュールから安全に import できるリーフモジュールとして設計されている。schemas.ts は最も多くのランタイム import を持つが、errors, api, json-schema などは import type に限定されている。
5. ローカル z オブジェクトによる循環回避
// packages/zod/src/v4/classic/from-json-schema.ts:1-13
import type * as JSONSchema from "../core/json-schema.js";
import { type $ZodRegistry, globalRegistry } from "../core/registries.js";
import * as _checks from "./checks.js";
import * as _iso from "./iso.js";
import * as _schemas from "./schemas.js";
import type { ZodNumber, ZodString, ZodType } from "./schemas.js";
// Local z object to avoid circular dependency with ../index.js
const z = {
..._schemas,
..._checks,
iso: _iso,
};from-json-schema.ts は通常なら import z from "../index.js" とするところ、index.ts への循環依存を避けるために、必要なモジュールを個別 import してローカルに z オブジェクトを組み立てている。barrel export(index.ts)を経由しない直接 import は、循環回避の基本的な手法である。
6. madge --circular による CI 防御と例外管理
// package.json:88
"check:circular": "madge --circular --extensions ts --exclude '(v4/core|v3|iso\\.ts)' packages/zod/src"v4/core 内部はスキーマ定義の性質上、相互参照が不可避であるため --exclude で除外している。ただし除外された領域は defineLazy と import type で循環の影響を制御済みである。v3 と iso.ts も同様に除外対象で、レガシーコードと特殊モジュールに対する明示的な例外管理となっている。
7. pnpm workspace の隔離設定
# pnpm-workspace.yaml
packages:
- packages/*
hoistWorkspacePackages: false
saveWorkspaceProtocol: false
linkWorkspacePackages: truehoistWorkspacePackages: false により各パッケージが独自の node_modules を持ち、暗黙の依存(phantom dependency)を防止している。saveWorkspaceProtocol: false は workspace:* プロトコルの自動付与を抑制し、publish 時の互換性を保つ。
8. tree-shaking 対応の /*@__PURE__*/ アノテーション
// packages/zod/src/v4/core/schemas.ts:185
export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constructor("$ZodType", ...);
// packages/zod/src/v4/core/core.ts:17
export /*@__NO_SIDE_EFFECTS__*/ function $constructor<T extends ZodTrait, D = T["_zod"]["def"]>(...)$constructor ファクトリ関数に @__NO_SIDE_EFFECTS__ を付け、各スキーマ定義に @__PURE__ を付けることで、バンドラーが未使用スキーマを安全に削除できる。package.json の "sideEffects": false と合わせて、tree-shaking の 3 層防御(パッケージレベル・関数レベル・呼び出しレベル)を実現している。
パターンカタログ
Lazy Initialization(遅延初期化) (分類: 生成)
- 解決する問題: モジュール間の循環依存による初期化時のエラー / undefined 参照
- 適用条件: 相互参照するモジュール間でプロパティを共有する必要がある場合
- コード例:
packages/zod/src/v4/core/util.ts:266-289(defineLazy) - 注意点: 循環検出時に
undefinedを返すため、呼び出し側でundefinedが来る前提のハンドリングが必要
Self-Caching Accessor(自己書き換えアクセサ) (分類: 振る舞い)
- 解決する問題: 高コストな計算結果のキャッシュ。Map/WeakMap による外部キャッシュ管理の複雑さ回避
- 適用条件: 計算結果が不変で、初回アクセス後に再計算が不要な場合
- コード例:
packages/zod/src/v4/core/util.ts:223-235(cached)、util.ts:613(assignPropセルフキャッシュ) - 注意点:
Object.definePropertyによる上書きはconfigurable: trueが前提。frozen オブジェクトには適用不可
Good Patterns
センチネル値による循環検出:
defineLazyはEVALUATINGSymbol をセンチネルとして使い、再帰的な getter 呼び出しを安全に短絡する。boolean フラグと異なり Symbol は名前空間を汚染せず、undefinedとの区別も確実にできる。typescript// packages/zod/src/v4/core/util.ts:264-278 const EVALUATING = Symbol("evaluating"); // getter 内: if (value === EVALUATING) { return undefined as T[K]; // 循環を検出して安全に中断 } if (value === undefined) { value = EVALUATING; value = getter(); }import typeと ランタイム import の分離:v4/core/util.tsは 4 つの import がすべてimport typeであり、他モジュールへのランタイム依存がゼロ。これにより、どのモジュールからでも安全に import できるリーフノードとなり、依存グラフの DAG 性を保っている。typescript// packages/zod/src/v4/core/util.ts:1-4 import type * as checks from "./checks.js"; import type { $ZodConfig } from "./core.js"; import type * as errors from "./errors.js"; import type * as schemas from "./schemas.js";barrel export を迂回するローカル組み立て:
from-json-schema.tsは index.ts(barrel)への循環依存を避けるため、必要なモジュールを個別 import してローカルにzオブジェクトを構築している。barrel export は利便性を提供する反面、循環の温床になりやすいため、内部モジュールからは直接 import する。typescript// packages/zod/src/v4/classic/from-json-schema.ts:8-13 // Local z object to avoid circular dependency with ../index.js const z = { ..._schemas, ..._checks, iso: _iso, };@__PURE__/@__NO_SIDE_EFFECTS__による tree-shaking 補助: スキーマ定義のファクトリ呼び出しに@__PURE__を付け、バンドラーに「この呼び出しは副作用がない」と伝える。Zod では 40 以上のスキーマ定義すべてにこのアノテーションを付与している。typescript// packages/zod/src/v4/core/core.ts:17 export /*@__NO_SIDE_EFFECTS__*/ function $constructor<T extends ZodTrait, D = T["_zod"]["def"]>(...) // packages/zod/src/v4/core/schemas.ts:185 export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constructor("$ZodType", ...);
Anti-Patterns / 注意点
barrel export 経由の内部 import: 内部モジュールが自パッケージの barrel export(index.ts)を import すると循環が発生しやすい。Zod ではこれを
from-json-schema.tsのコメントで明示的に警告している。Bad:
typescript// 内部モジュールから barrel export を import — 循環依存の原因 import z from "../index.js";Better:
typescript// 必要なモジュールのみ直接 import import * as _checks from "./checks.js"; import * as _schemas from "./schemas.js"; const z = { ..._schemas, ..._checks };循環依存の暗黙的許容:
madge --excludeで循環を除外する場合、除外範囲が広がると検出能力が低下する。Zod では除外対象をv4/core(設計上不可避)、v3(レガシー)、iso.ts(特殊)の 3 つに限定し、新規モジュールは原則として除外しない方針を取っている。Bad:
bash# 除外範囲を広げすぎて検出能力が失われる madge --circular --exclude '(core|classic|mini)' packages/zod/srcBetter:
bash# 除外は最小限に絞り、理由をコメントで明記する madge --circular --exclude '(v4/core|v3|iso\.ts)' packages/zod/src
導出ルール
[MUST]循環依存が不可避なモジュール間では、遅延初期化(Object.defineProperty getter)を使ってプロパティアクセスを初期化時点から切り離す- 根拠: Zod の
defineLazyはEVALUATINGセンチネルで循環検出を行い、40 箇所以上のスキーマ初期化で使用されている(schemas.ts全体)
- 根拠: Zod の
[MUST]CI に静的循環依存チェックツール(madge 等)を組み込み、既知の例外は--excludeで最小限に管理する- 根拠:
.github/workflows/test.yml:50-60でcheck-circularジョブが PR ごとに実行され、除外対象は 3 パターンに限定されている
- 根拠:
[SHOULD]型のみが必要な import はimport typeを使い、ランタイム依存グラフから除外する- 根拠:
v4/core/util.ts:1-4は 4 つの import すべてがimport typeであり、ランタイム依存ゼロのリーフモジュールとして機能している
- 根拠:
[SHOULD]高コストだが不変な計算結果は、セルフキャッシュ getter(初回アクセスで自身を実値に上書き)で遅延評価する- 根拠:
pick/omit/extend/merge/partial/requiredの 6 関数すべてでassignProp(this, "shape", ...)パターンが使用されている(util.ts:613-793)
- 根拠:
[SHOULD]tree-shaking 対応ライブラリでは、ファクトリ関数に@__NO_SIDE_EFFECTS__、呼び出し側に@__PURE__を付与し、package.jsonに"sideEffects": falseを設定する- 根拠:
core.ts:17のファクトリ定義とschemas.tsの 40 以上のスキーマ定義すべてにアノテーションが付与されている
- 根拠:
[SHOULD]内部モジュールは barrel export(index.ts)を経由せず、必要なモジュールを直接 import する- 根拠:
from-json-schema.ts:8のコメント「Local z object to avoid circular dependency with ../index.js」
- 根拠:
[AVOID]ライブラリの内部モジュールから自パッケージの barrel export を import する — 循環依存の最も一般的な原因となる- 根拠:
from-json-schema.tsで明示的にこのパターンを回避し、個別 import で代替している
- 根拠:
[AVOID]循環依存チェックの除外範囲を安易に拡大する — 新規モジュールの追加時に循環が検出されなくなる- 根拠: Zod の
check:circularは除外をv4/core(設計上不可避)、v3(レガシー)、iso.ts(特殊)の 3 つのみに限定している
- 根拠: Zod の
適用チェックリスト
- [ ] プロジェクトの循環依存を
madge --circular --extensions ts src/で検出し、現状を把握する - [ ] 検出された循環依存のうち、設計上不可避なものは
defineLazyパターンで遅延初期化に置き換える - [ ] 型のみが必要な import を
import typeに置き換え、ランタイム依存を削減する - [ ] CI に
madge --circularチェックを追加し、新規循環依存の混入を自動検出する - [ ] barrel export(index.ts)を内部モジュールから import している箇所を特定し、直接 import に置き換える
- [ ] ライブラリを公開している場合、
package.jsonに"sideEffects": falseを設定し、ファクトリ関数に@__PURE__/@__NO_SIDE_EFFECTS__を付与する - [ ]
pnpm-workspace.yamlでhoistWorkspacePackages: falseを設定し、phantom dependency を防止する