Zod v4 スキーマ実装ガイド
Zod v4 でランタイム + 型レベルのバリデーションを書くパターン。基本のスキーマ定義から transform / refinement / discriminated union / メッセージ国際化までを 1 ページに統合。
Zod v4 で型の境界を引く
@zod v4 の基本スキーマから refinement / transform / discriminated union / safeParse まで、型の境界で実際に詰まったポイントを残しながら整理した実装メモ。
検証日: 2026-05-09
使用バージョン:
zod@4.3.x環境: Node 22.12 / TypeScript 6.0
想定読者: TypeScript で API / フォーム / env を扱う、ランタイム検証ライブラリを評価中の人
TypeScript の型は build 時にしか効きません。だから 外部から来る値(API レスポンス、フォーム入力、環境変数、LLM の構造化出力)を信用するためには、ランタイム検証が必要になります。
Zod は 2026 年時点で「TypeScript の型 + ランタイム検証」を一本で扱えるライブラリの代表格。v4 系で API が安定し、エコシステムも成熟してきました。
なぜ Zod を試したか
別の検証ツール(ajv / yup / valibot / typia)と比べた時、Zod が選ばれる典型ケース:
- 型推論を主目的にする(
z.infer<typeof schema>で型と検証を 1 つの宣言から派生させたい) - react-hook-form / Astro Content Collection / tRPC / next-safe-action などのエコシステムを使う
- LLM の構造化出力を検証する(OpenAI structured output と zod schema を組み合わせる需要が増えている)
逆に、JSON Schema が一次資料の現場 / 1 ms 以下が要求される hot path では別ライブラリのほうが向きます(後述)。
最小サンプル(parse と safeParse の違い)
z.object で schema を定義 → z.infer<typeof schema> で 型を自動派生(1 つの宣言で型 + ランタイム検証):
import { z } from "zod";
const User = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0),
});
type User = z.infer<typeof User>;
// { id: string; email: string; age: number }
検証の呼び方は 2 通り:
// 1) parse: 失敗時に throw する。境界の入口で例外許容ならこれ
const user = User.parse(input);
// 2) safeParse: Result 型を返す。throw しない、安全
const result = User.safeParse(input);
if (!result.success) {
console.error(result.error.issues);
return;
}
const user = result.data;
refinement(複数フィールドにまたがる検証)
「new password が confirm password と一致する」のような単一フィールドでは表現できない制約は .refine() で書きます。
const PasswordChange = z
.object({
current: z.string(),
next: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.next === data.confirm, {
message: "確認用パスワードが一致しません",
path: ["confirm"],
})
.refine((data) => data.current !== data.next, {
message: "新しいパスワードは現在と異なる必要があります",
path: ["next"],
});
path: ["confirm"] を指定すると、エラーをフォームの該当フィールドに紐付けやすくなります(react-hook-form の resolver と組み合わせる時に効く)。
transform(検証 + 変換)
URL クエリパラメータは全部 string なので、数値や bool に変換しながら検証する場面が頻出。
const StringNumber = z.string().regex(/^\d+$/).transform((s) => Number(s));
type Input = z.input<typeof StringNumber>; // string
type Output = z.output<typeof StringNumber>; // number
type Default = z.infer<typeof StringNumber>; // number(infer = output)
z.input と z.output を別途取り出せるのが地味に便利。「サーバ側で受け取る生の型」と「アプリ内部で扱う型」を分離する時に効きます。
discriminated union(タグ付き union)
イベント / メッセージ / レスポンス型のような 「種類で分岐する」型に最適:
const Event = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("scroll"), top: z.number() }),
z.object({ type: z.literal("close"), reason: z.string() }),
]);
type Event = z.infer<typeof Event>;
function handle(e: Event) {
switch (e.type) {
case "click": return [e.x, e.y]; // x / y は number に narrow
case "scroll": return e.top;
case "close": return e.reason;
}
}
通常の z.union() でも書けますが、discriminator が決まっているなら discriminatedUnion のほうがエラーメッセージが明確 + パースが速い。
環境変数のスキーマ(fail-fast)
process.env は型が string か undefined。zod schema で 必須化 + 型変換 + 検証 を 1 箇所にまとめる:
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
});
export const env = Env.parse(process.env);
z.coerce.number() は文字列 → number 変換を最初にかけるので、env がすべて string でくるケースに便利。アプリ起動時に Env.parse(process.env) で fail-fast にすると、起動した瞬間に設定不備が検知できる。
つまずいたポイント
.optional()と.nullable()と.default()を混同して、想定外のパース失敗 —.optional()= キーが無い OK、.nullable()= null OK、.default(v)= 未指定時に v で埋める。役割が違うz.coerce.boolean()が文字列"false"を true にする — 空でない文字列はすべて truthy になるため。bool 文字列はz.enum(["true", "false"]).transform(v => v === "true")のように明示的に書く.partial()は浅い — ネストしたオブジェクトの全フィールドを optional にしたい場合は.deepPartial()か手動で書く必要がある- 再帰スキーマで型エラー —
z.object({ children: Tree.array() })のような自己参照はz.lazy(() => Tree)で囲まないと型推論が回らない - デフォルトで余計なキーが drop される — レスポンスの一部だけ検証したい場合は
.passthrough()を明示。逆に余計なキーで fail させたければ.strict()
評価
| 観点 | 評価 | コメント |
|---|---|---|
| 学習コスト | ◎ | 公式ドキュメントが充実、最小サンプルがそのまま動く |
| 型推論 | ◎ | z.infer で型と検証が 1 ソース化 |
| エラーメッセージ | ○ | path を指定すれば form 統合で扱いやすい |
| パフォーマンス | ○ | 数千件規模までは余裕、hot path では計測必須 |
| エコシステム | ◎ | react-hook-form / Astro / tRPC / next-safe-action と統合多数 |
向く / 向かないケース
- 向く: TypeScript プロジェクトの型境界全般、フォーム検証、env validation、LLM の構造化出力検証
- 向かない: 1 ms 以下が必要な極端な hot path(
typia/valibotの AOT を検討) - 向かない: JSON Schema が一次資料の環境(OpenAPI 中心の現場では
ajv直接使用も合理的)
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
Effective TypeScript(第2版) : 型システムの力を最大限に引き出す83項目
急速に普及が進んでいるTypeScriptの実用書! TypeScriptの実用書。TypeScript…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
かんたん TypeScript
本書は、「広く・正しく・新しく」をコンセプトにTypeScriptでプログラミングをはじめるにあたって基本的なことはすべて学習できる内容となっています。また、イラストによる図解方式で概念をやさしく解説している…
ゼロからわかる TypeScript入門
プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで
TypeScriptは、JavaScriptに静的型付けの機能を加えたオープンソースのプログラミング言語です。本書では、根幹となるJavaScr…
Zod v4 で discriminated union / transform / brand を組む
Zod v4 の踏み込んだスキーマ設計 — discriminated union による switch ディスパッチ、transform + pipe で正規化チェーン、branded type で同型異種を区別、superRefine で複数フィールド検証を触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
zod@4.4.0対象: 入門は通っている、Zod を本番アプリの「型の境界」として一段深く使いたい人
入門記事は /articles/zod-v4-schema-patterns/(基本スキーマ / parse vs safeParse / refinement)を参照。本稿は 本番運用で必要になる中級〜上級パターンのみ。
触って試す
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), content: z.string() }),
z.object({ kind: z.literal("image"), url: z.string().url(), alt: z.string() }),
z.object({ kind: z.literal("link"), href: z.string().url(), label: z.string() }),
])✓ success
{
"kind": "image",
"url": "https://example.com/cat.png",
"alt": "cat"
}4 つのパターン × 入力を編集して safeParse の結果を即時確認できる。
1. discriminated union(タグ付き union)
type(または kind)で分岐する型は共通設計パターン。Zod では z.discriminatedUnion を使うと:
- パースが速い(該当 variant だけ検証、union 全部試さない)
- エラーメッセージが明確(「kind=image なら url が必要」と該当 variant のみ報告)
- TypeScript の narrowing が自然(
switch (msg.kind)で各 case に絞り込まれる)
const Message = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), content: z.string() }),
z.object({ kind: z.literal("image"), url: z.string().url(), alt: z.string() }),
z.object({ kind: z.literal("link"), href: z.string().url(), label: z.string() }),
]);
type Message = z.infer<typeof Message>;
function render(m: Message) {
switch (m.kind) {
case "text": return <p>{m.content}</p>;
case "image": return <img src={m.url} alt={m.alt} />; // url, alt が確実にある
case "link": return <a href={m.href}>{m.label}</a>;
}
}
Server route の dispatch にも有効:
const ApiRequest = z.discriminatedUnion("type", [
z.object({ type: z.literal("create"), name: z.string(), email: z.string().email() }),
z.object({ type: z.literal("update"), id: z.string(), patch: z.object({}).passthrough() }),
z.object({ type: z.literal("delete"), id: z.string() }),
]);
const body = ApiRequest.parse(await req.json());
switch (body.type) { /* type-safe */ }
2. transform chain + pipe
文字列の正規化(trim → lowercase → email 検証)のような 多段パイプラインは transform + pipe で表現できる。
const NormalizedEmail = z.string()
.trim()
.min(1, "空欄不可")
.transform((s) => s.toLowerCase())
.pipe(z.string().email("メールアドレス形式が不正"));
NormalizedEmail.parse(" Foo@Example.COM ");
// → "foo@example.com"
ポイント:
.transformの前後で型が変わる:transform で string → string でも、別の Zod schema として扱われる.pipe(...)で次段の検証に値を流す:transform の出力を別 schema で再検証- エラーは段で出る:どこで失敗したかが
issuesで取れる(.pathでステップ位置も追える)
ポイント:
transformは型を変える:string → number, Date → string などにも使えるpipeで次の検証へ繋ぐ:transform の結果を別 schema で検証- エラー報告は最も近い fail 時点:trim 後に空文字なら
min(1)のメッセージ
z.input<typeof NormalizedEmail> と z.output<typeof NormalizedEmail> が違う型になる(input は元の string、output は正規化済 email)。フォームの「ユーザに見せる入力」と「アプリ内部で扱う値」を分離したい時に効く。
3. branded type(同型異種を区別)
UserId も PostId も中身は string の UUID。型レベルで間違って渡さないように branded type を使う。
const UserId = z.string().uuid().brand<"UserId">();
const PostId = z.string().uuid().brand<"PostId">();
type UserId = z.infer<typeof UserId>; // string & { __brand: "UserId" }
type PostId = z.infer<typeof PostId>;
function fetchUser(id: UserId) { /* ... */ }
const u = UserId.parse(rawString); // OK
const p = PostId.parse(rawString);
fetchUser(u); // OK
fetchUser(p); // ✗ コンパイルエラー(Brand が違う)
fetchUser("550e8400-..."); // ✗ コンパイルエラー(plain string)
DB の主キーや、API のリソース ID をすべて branded にすると「取り違えバグ」が型レベルで止まる。
4. superRefine(複数フィールド検証 + 詳細 ctx)
refine では 1 つの error しか出せない。複数フィールドの cross check や条件分岐検証は superRefine を使う。
const Period = z.object({
start: z.coerce.date(),
end: z.coerce.date(),
type: z.enum(["once", "recurring"]),
interval: z.number().int().optional(),
}).superRefine((d, ctx) => {
if (d.start >= d.end) {
ctx.addIssue({
code: "custom",
message: "終了日は開始日より後にしてください",
path: ["end"],
});
}
if (d.type === "recurring" && d.interval == null) {
ctx.addIssue({
code: "custom",
message: "繰り返しの場合 interval は必須",
path: ["interval"],
});
}
});
ctx.addIssue を複数回呼べば、検証済の全エラーが issues[] に並ぶ。path で具体的なフィールドに紐付け。
react-hook-form の resolver と組み合わせる時、path がそのまま errors.<field>.message に対応するので UX 的に強い。
5. .extend / .merge / .pick / .omit でのスキーマ合成
Zod schema は オブジェクト合成できる。実テンプレ:
const Common = z.object({
id: z.string().uuid(),
createdAt: z.coerce.date(),
});
// extend: 追加
const User = Common.extend({
email: z.string().email(),
name: z.string(),
});
// merge: 別 schema を統合(同じキーは後勝ち)
const UserWithRole = User.merge(z.object({ role: z.enum(["admin", "user"]) }));
// pick / omit: 入出力で形を変える
const UserCreateInput = User.omit({ id: true, createdAt: true });
const UserPublicProfile = User.pick({ id: true, name: true });
フォームの「create 時は id 不要、read 時は id 必須」のような 同一エンティティの異なる断面 を表現できる。
6. 環境変数の堅牢な検証
process.env を schema で検証して、失敗時はアプリ起動を止める(fail-fast):
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
// 任意フィールドだが、ある時は形式厳密
SENTRY_DSN: z.string().url().optional(),
// ある条件下でだけ必須
STRIPE_SECRET: z
.string()
.startsWith("sk_")
.optional()
.superRefine((v, ctx) => {
if (process.env.NODE_ENV === "production" && !v) {
ctx.addIssue({ code: "custom", message: "本番では Stripe キー必須" });
}
}),
});
export const env = Env.parse(process.env);
アプリ起動時に呼べば、設定不備が runtime ではなくプロセス起動時点で fail-fast。
7. safeParse + retry(LLM 出力など)
LLM の構造化出力をパースする時、最初の試行で形式エラーになることがある。retry パターン:
async function parseLlmOutput<T extends z.ZodType>(
schema: T,
prompt: string,
maxAttempts = 3,
): Promise<z.infer<T>> {
let lastError: z.ZodError | null = null;
for (let i = 0; i < maxAttempts; i++) {
const completion = await callLlm(prompt + (lastError ? `\n前回のエラー: ${lastError.message}` : ""));
const result = schema.safeParse(completion);
if (result.success) return result.data;
lastError = result.error;
}
throw new Error(`LLM output validation failed after ${maxAttempts} attempts: ${lastError?.message}`);
}
LLM に「前回のエラー」を伝えて出し直してもらう、という型駆動の self-correcting パターン。
つまずいたポイント
z.unionvsz.discriminatedUnion:単純 union でも動くが、判別子があるなら必ず discriminated。エラーメッセージとパース速度が桁違いbrand<"X">()は型のみのマーク:runtime には影響なし。UserId.parse(s)で実体は string、型情報だけ tag が付くtransformの戻り値がz.NEVER:ctx.addIssue したらreturn z.NEVERで型を絞ると後段が安全coerceとpreprocessの使い分け:coerce はz.coerce.number()のような shorthand、preprocess はもっと自由度高い前処理(空文字 → null など).optional()チェインの順序:.optional().refine(...)と.refine(...).optional()で挙動が変わる(前者は undefined を許容、後者は値があるものに refine)z.object({}).passthrough()を pick / omit すると passthrough が外れる:合成後に再度.passthrough()を付ける必要
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
Effective TypeScript(第2版) : 型システムの力を最大限に引き出す83項目
急速に普及が進んでいるTypeScriptの実用書! TypeScriptの実用書。TypeScript…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
かんたん TypeScript
本書は、「広く・正しく・新しく」をコンセプトにTypeScriptでプログラミングをはじめるにあたって基本的なことはすべて学習できる内容となっています。また、イラストによる図解方式で概念をやさしく解説している…
ゼロからわかる TypeScript入門
プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで
TypeScriptは、JavaScriptに静的型付けの機能を加えたオープンソースのプログラミング言語です。本書では、根幹となるJavaScr…
次に読むガイド
- 2 セクション統合
TypeScript 上級活用ガイド
TypeScript の踏み込んだ型テクニック(conditional types / template literal types / branded types など)と、v6 → v7 のメジャーアップグレード時に当たる主要な変更点・移行ノートを 1 ページに統合。実コード付き。
- 2 セクション統合
MDX 実装テクニック
MDX でドキュメント / 技術記事を書くための実装ノート。Shiki によるコードハイライト(dual theme / 差分表示 / 行ハイライト)、MDX に外部コンポーネントを注入するパターン(components prop / scope)までを 1 ページに統合。