コンパイル済みスキーマでZodのバリデーションを高速化する
Zodのコンパイル済みスキーマで検証を高速化する方法、効果が出る場面、コストや非対応スキーマ、CSPとCloudflare Workersの制約を解説します。
Zod 4.5.0以降、z.compile() を使うと、スキーマを専用のJavaScriptバリデーターに変換できます。有効な入力をより高速にチェックでき、しかもコンパイル前のスキーマとまったく同じ結果とエラーを返します。
ハンドラーが同じ大きなリクエストボディを毎分何千回もパースしている場合、呼び出しのたびにスキーマツリーをたどるバリデーション層は、CPUプロファイル上で無視できない負荷になります。Zod 4.5 リリースでは、この問題への対策が追加されました。本記事は、OpenReplayのガイド「Zodを使ったTypeScriptでのデータバリデーション」の続編です。コンパイルの有効化方法とそのコスト、高速化の効果が実際に意味を持つケース、そして厳格なContent Security Policy(CSP)下やCloudflare Workersなどのランタイムでの挙動について解説します。
重要なポイント
z.compile(schema)は、ループも分岐を伴うツリー走査も含まないバリデーターを生成し、new Function()を通じて実行します。有効な入力は、スキーマツリーを順にたどるのではなく、typeofチェックを単純に順番に実行するだけで検証されます。- 無効な入力に対しては、ほとんど効果がありません。パースが失敗した場合、まず高速パスが実行され、その後エラーを構築するために完全な標準パーサーが実行されるためです。
- Zod自身のタイトループベンチマークでは、キー数が5、10、20、50のオブジェクトをコンパイルすると、それぞれ1.8倍、2.2倍、5.0倍、10.2倍高速になります。つまり、小さなスキーマではほとんど効果がありません。
z.compile()を呼び出す、またはzod/compileをインポートするバンドルでは、コンパイラーによってサイズが約7 KB(gzip圧縮後)増加します。{ strict: true }を指定すると、コンパイルできないスキーマは暗黙的にフォールバックせず、ZodCompileAsyncErrorまたはZodCompileUnsupportedErrorをスローします。
コンパイル済みZodスキーマの仕組み
コンパイル済みのZodスキーマは、スキーマツリーをたどる代わりに、生成されたコードで入力を検証します。Zodはスキーマ全体を一度だけ読み取り、ループを含まない短いJavaScriptコードを書き出し、それを new Function() で関数化します。以降、有効な入力は、各プロパティを読み取ってその typeof を1行ずつテストするだけで検証されます。高速パスで入力が拒否された場合、Zodはその入力に対して標準パーサーを実行します。そのため、コンパイル済みスキーマは元のスキーマと同じissueとエラーメッセージを報告します。.parse()、.safeParse() や推論される型も、これまでどおり使用できます。
コンパイルを有効にする
コンパイルを有効にする方法は2つあります。z.compile() で特定のスキーマをコンパイルする方法と、zod/compile をグローバルにインポートしてアプリ内のすべてのスキーマをコンパイルする方法です。スキーマ単位のコンパイルでは、ホットパスを細かく制御できます。グローバルモードは1行追加するだけで済みます。
z.compile() によるスキーマ単位のコンパイル
z.compile() は、コンパイル済みの新しいスキーマを返します。渡した元のスキーマは変更されません。.refine()、.extend()、.optional() など、コンパイル済みスキーマから新しいスキーマを構築するメソッドは、いずれもコンパイルされていないスキーマを返します。まずスキーマを完成させ、最後にコンパイルしてください。
import * as z from "zod";
const Base = z.object({
id: z.string(),
type: z.string(),
createdAt: z.number(),
});
const notInFuture = (e: { createdAt: number }) => e.createdAt <= Date.now();
// ❌ .refine() returns a new schema, and it is not compiled
const Wrong = z.compile(Base).refine(notInFuture);
// ✅ finish the schema, then compile it
const WebhookEvent = z.compile(Base.refine(notInFuture));
const result = WebhookEvent.safeParse(payload);
Wrong がコンパイルされていないことは、実行時には何も通知されません。バリデーション自体は正しく動作し、単に遅くなるだけです。後述する strict オプションでもこれは検出できません。strict はスキーマがそもそもコンパイルできない場合にのみ例外をスローするためです。安全な習慣として、z.compile() をメソッドチェーンの最後に呼び出すようにしましょう。
zod/compile によるグローバルなコンパイル
デフォルトでコンパイルするモードでは、インポート後に作成された各スキーマがZodによってコンパイルされます。コンパイルは遅延実行され、各スキーマが最初にパースされるタイミングで行われます。
import "zod/compile"; // must run before any module that defines schemas
import * as z from "zod";
const User = z.object({ name: z.string() });
User.parse({ name: "ok" }); // compiled on first parse
ESMではロード順序を誤りやすいため、ランタイムに最初にこのモジュールを読み込ませるようにしましょう。
node --import zod/compile app.js # ESM
node --require zod/compile app.cjs # CommonJS
Bunを使用している場合は、代わりに bunfig.toml の preload に zod/compile を指定できます。グローバルモードはアプリケーション向けの機能です。ライブラリが利用者側でこれを有効にすべきではありません。
Zodのコンパイルにかかるコスト
Zodスキーマのコンパイルには3つのコストがあり、また無効な入力に対してはほとんど効果がありません。入力が拒否されるパースでは、高速パスが実行されて失敗した後、完全な標準パーサーが実行され、処理時間のほぼすべてをそちらが占めます。3つのコストは以下のとおりです。
- スキーマごとの初回コンパイル処理:スキーマのコンパイル時、またはグローバルモードでは最初のパース時に実行されます。
- バンドルサイズ:コンパイラーによって約7 KB(gzip圧縮後。minify後は28 KB)が追加されます。Zod公式の表によると、4つのキーを持つオブジェクトスキーマの場合、コンパイラーなしでは24.1 KB(gzip圧縮後)ですが、コンパイラーありでは31.1 KBになります。Zod Miniではさらに増加幅が大きく、4.6 KBから13.2 KBになります。
z.compile()を呼び出さず、zod/compileもインポートしないバンドルでは、ツリーシェイキングによってコンパイラーは完全に除去されます。 - 失敗時の二重実行:refinementやtransformは、入力が有効な場合は1回だけ実行されますが、無効な場合は2回実行される可能性があります。ログ出力、カウント、書き込みなどを行うrefinementは、拒否されたWebhookに対してそれらの処理を2回実行することになります。
継続的なトラフィックがあるリクエスト境界では、初回のコンパイルコストはすぐに回収できます。一方、単発のスクリプトや、設定ファイルを1つパースするだけのCLIでは、コンパイル処理や増加したバイト数を回収できない可能性があります。ボットによるプローブや偽造されたWebhook署名など、不正な入力が大半を占めるトラフィックでも、得られる効果はわずかです。
Zodのパフォーマンス向上が顕著に現れるケース
コンパイルによるZodのパフォーマンス向上は、スキーマのサイズに比例して大きくなります。最も恩恵を受けるのは、キー数の多いオブジェクトやタプルです。生成されたコードは、標準パーサーのようなキーごとのループを使わずに、各キーを順番にチェックするためです。Zod公式のベンチマークでは、各スキーマを個別に、タイトループで繰り返し計測しています。この条件下では標準パーサーが最大限の性能を発揮するため、ここでの向上率は、同じページの冒頭にあるメインチャートの数値よりも控えめになっています。
| スキーマ | 高速化率 |
|---|---|
| オブジェクト(キー5個) | 1.8倍 |
| オブジェクト(キー10個) | 2.2倍 |
| オブジェクト(キー20個) | 5.0倍 |
| オブジェクト(キー50個) | 10.2倍 |
| タプル(要素1個) | 2.2倍 |
| タプル(要素3個) | 2.5倍 |
| タプル(要素5個) | 3.0倍 |
| タプル(要素10個) | 3.7倍 |
フィールドが3つしかないログインフォームでは、違いを体感することはないでしょう。一方、リクエストごとにパースされる50キーのイベントペイロードでは、違いがはっきりと現れます。zod-compiler で謳われている「最大44倍」(拒否される入力では最大46倍)といった大きな数値は、ビルド時にバリデーターを生成する別のサードパーティ製ツールによるものです。これはZodに組み込まれたランタイムコンパイラーの性能を示すものではありません。
コンパイルできないZodスキーマ
Zodスキーマの一部の機能はコンパイルできません。z.compile() はそうした機能を検出しても例外をスローせず、渡されたスキーマをコンパイルせずにそのまま返します。非対応機能の一覧によって、スキーマ全体がフォールバックするのか、特定の子スキーマだけがフォールバックするのかが決まります。
| 機能 | 影響 |
|---|---|
| ツリー内のいずれかの場所にある非同期のrefinement、transform、check | スキーマ全体がフォールバック |
コールバックを使った .catch()(.catch(value) はコンパイル可能) | スキーマ全体がフォールバック |
| 非対応のメンバーを含むunion | スキーマ全体がフォールバック |
z.xor()、再帰スキーマ、z.coerce.*、カスタム when を持つcheck | コンパイルされない |
| object、array、tuple、record、intersection内の非対応の子スキーマ | その子スキーマのみ標準パーサーを使用 |
z.encode() や非同期パースにはコンパイルが一切適用されません。どちらも常に標準パーサーを経由します。ハンドラーで safeParseAsync を呼び出している場合、スキーマをコンパイルしても効果はありません。
後からの変更によってホットパスのコンパイルが気づかないうちに無効化されるのを防ぐには、CIで { strict: true } を指定してコンパイルを実行します。
import { test } from "node:test";
import assert from "node:assert/strict";
import * as z from "zod";
import { WebhookEvent, OrderBody } from "../src/schemas.js";
test("hot-path schemas compile", () => {
for (const schema of [WebhookEvent, OrderBody]) {
assert.doesNotThrow(() => z.compile(schema, { strict: true }));
}
});
test("async refinements are rejected under strict", () => {
const Handle = z.string().refine(async (v) => v.length > 2);
assert.throws(() => z.compile(Handle, { strict: true })); // ZodCompileAsyncError
});
strict を指定した場合、非同期スキーマでは ZodCompileAsyncError が、それ以外のコンパイルできないスキーマでは ZodCompileUnsupportedError がスローされます。strict を指定しない場合は、どちらのエラーもスローされません。
z.compile() はCSP下やCloudflare Workersで動作するか
動的なコード生成が禁止されている環境では、z.compile() は安全に失敗しますが、何の効果もありません。Content Security Policyの script-src に 'unsafe-eval' が含まれていないページ(script-src がない場合は default-src に含まれていないページ)では、new Function() がブロックされます。また、Zodは new Function() がブロックされる環境としてCloudflare Workersを挙げています。jitless を設定すると、グローバルモードは自動的に無効になります。
// config.ts: import this module before any module that defines schemas
import * as z from "zod";
z.config({ jitless: true });
ただし、z.compile() を明示的に呼び出す場合は挙動が異なります。Zodはこれを明確な要求として扱うため、jitless が有効でもコード生成を試みます。環境が new Function をブロックしている場合は、スキーマがコンパイルされずにそのまま返されるだけです。バリデーションは引き続き動作します。しかし、決して実行されることのないコンパイラー(gzip圧縮後で約7 KB)を配布してしまうことになるため、そうした環境を対象とするビルドでは zod/compile や z.compile() を使用しないようにしましょう。
まとめ
コンパイルを使うと、結果やエラーを変えることなく有効な入力のチェックを高速化でき、その効果はスキーマの幅(キー数)に比例して大きくなります。まずはリクエスト境界のプロファイリングから始めましょう。キー数が多く、頻繁に使われるスキーマは、構築チェーンの最後でコンパイルし、strict テストを追加してコンパイル可能な状態を維持してください。new Function をブロックするランタイムやCSPポリシーを対象とするビルドには、コンパイラーを含めないようにしましょう。コンパイラーの修正を取り込めるよう、4.5.0に固定するのではなく、最新のZod 4リリースをインストールしてください。
よくある質問
Zodで無効な入力を拒否する際に、safeParseより高速な方法はありますか?
はい。Zod 4.6で追加された.validate()は、入力が有効かどうかだけを返します。ZodErrorの構築をスキップするため、不正な入力を拒否する際のコストはごくわずかです。コンパイル済みスキーマで無効な入力を処理する場合、.safeParse().successと比べて最大35倍高速になります。また、型の絞り込み(ナローイング)も行われ、trueを返した場合、TypeScriptはその値をスキーマの入力型として扱います。「はい/いいえ」の判定だけが必要な場合は.validate()を使い、エラーの詳細が必要な場合は引き続き.safeParse()を使用してください。
new Functionがブロックされる環境で、事前コンパイル済みのZodパーサーを使用できますか?
はい、Zod 4.6以降で可能です。z.compile()は、パーサーを生成する処理と、それをスキーマにアタッチする処理の2つを担っています。z.withParser()は、このうち後者の処理のみを行います。ビルドステップやネイティブコンパイラーなど、別の場所で生成したパーサーを渡すと、z.compile()と同じルールに従ってそのパーサーをアタッチします。生成されたコードは通常のJavaScriptとして配布されるため、ランタイムでnew Functionを使う必要は一切ありません。
z.compile()とzod-compilerの違いは何ですか?
z.compile()はZodに組み込まれた機能で、プロセス内でnew Function()を使って実行時にバリデーターを生成します。一方、zod-compiler(gajus/zod-compiler)は別のサードパーティ製ツールで、Vite、webpack、esbuild、Rollupなどのバンドラープラグイン、またはCLIを通じて、ビルド時にバリデーターを生成します。ビルド時の出力はevalを使わない通常のコードであるため、CSPのルールによって無効化されることはありません。ただし、オプションのランタイムモードではnew Functionを使用します。zod-compilerの最新リリースはZod 4.5以降が必要で、1.x系はZod 4.0~4.4に対応しています。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k