zod-v4-schema-patterns
TypeScript プロジェクトで **「外部から来る値を信用する境界」**(API レスポンス / フォーム入力 / 環境変数 / LLM 構造化出力 / DB 行)に実行時バリデーションを入れる時に呼び出すべき skill。Zod v4 系の基本スキーマ、`parse` vs `safeParse` の選び分け、`refine` / `superRefine` で複数フィールド検証、`transform` + `pipe` で正規化チェーン、discriminated union、型推論(`z.infer`)、エラーハンドリング、エコシステム連携(react-hook-form / tRPC / Astro Content / next-safe-action)、よく詰まる落とし穴をすべて含む。次のいずれかに該当する時に invoke する: (1) `import { z } from 'zod'` を含むファイルを編集中、(2) ユーザーが 'schema' / 'validation' / 'parse' を実装したい、(3) API endpoint や form の入力検証を組む場面、(4) LLM の構造化出力を型安全に受ける場面。検証バージョン: zod@4.3.x、検証日: 2026-05-09。
Zod v4 スキーマパターン skill
検証バージョン:
zod@4.3.x検証日: 2026-05-09 対象: TypeScript プロジェクトで実行時バリデーション + 型推論を扱う実装作業
Zod は 「型の境界」(API 入力 / form / env / DB 行 / LLM 出力 / etc.)で使う実行時バリデーションライブラリ。 TypeScript の型は build 時にしか効かないので、外部から来る値を信用するために必須に近い存在。
まず詰まる 3 点
parse()は throw、safeParse()は Result 型を返す。境界で throw が許容できる文脈(API handler の冒頭)以外はsafeParse()一択z.infer<typeof schema>は schema 定義の 後 に書く。schema をas constで凍結する必要は無い(zod 自体が型を保持する).optional()と.nullable()と.default()は別物。混同するとパースが失敗する(後述)
基本スキーマ(7 種)
import { z } from "zod";
const Email = z.string().email();
const Age = z.number().int().min(0).max(150);
const Url = z.string().url();
const NonEmpty = z.string().min(1);
const Iso8601 = z.string().datetime();
const Uuid = z.string().uuid();
const Enum = z.enum(["draft", "published", "archived"]);
Enum の型は自動で union("draft" | "published" | "archived")になる。後で .options で配列も取り出せる。
オブジェクトと部分指定
const User = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0),
bio: z.string().optional(), // 無いか string
nickname: z.string().nullable(), // null か string
status: z.enum(["active", "banned"]).default("active"),
});
type User = z.infer<typeof User>;
// { id: string; email: string; age: number; bio?: string;
// nickname: string | null; status: "active" | "banned" }
.optional() vs .nullable() vs .default() の使い分け:
| メソッド | input で許容 | output 型 | 使い分け |
|---|---|---|---|
.optional() | undefined | T | undefined | キーが存在しないかもしれない |
.nullable() | null | T | null | キーは必ずあるが null 許容 |
.default(v) | undefined | T(必ず値あり) | 未指定時にデフォルト値で埋める |
API レスポンスの「null」と「キーが無い」を区別する 設計がしばしば重要(REST よりも GraphQL でこの差が顕在化する)。
refinement(複数フィールド検証)
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"], // ← エラー位置を confirm に集める
}).refine((data) => data.current !== data.next, {
message: "新しいパスワードは現在と異なる必要があります",
path: ["next"],
});
path を指定すると、エラーをフォームの該当フィールドに紐付けやすい(react-hook-form 等の resolver と組み合わせる時に効く)。
transform(検証 + 変換)
zod は 検証だけでなく変換もできる。input の型と output の型が違うケース:
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)
URL クエリパラメータ(全部 string)から数値や bool を作る時に頻出。
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>;
// 利用側で type による narrowing が効く
function handle(e: Event) {
switch (e.type) {
case "click": return [e.x, e.y]; // ← x / y は number に narrow
case "scroll": return e.top; // ← top は number
case "close": return e.reason; // ← reason は string
}
}
普通の z.union() でも書けるが、discriminator が決まっているなら discriminatedUnion のほうがエラーメッセージが明確 + パースが速い。
エラーハンドリング(safeParse)
const result = User.safeParse(input);
if (!result.success) {
// result.error は ZodError
for (const issue of result.error.issues) {
console.error(issue.path.join("."), issue.message);
}
return { ok: false, errors: result.error.issues };
}
const user = result.data; // ← User 型として narrow されている
safeParse() は { success: true, data } | { success: false, error } を返す。例外を投げないので、API handler / form / バックエンドの境界で安心して使える。
環境変数のスキーマ
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),
REDIS_URL: z.string().url().optional(),
});
export const env = Env.parse(process.env);
z.coerce.number() は文字列 → number 変換を最初にかける(env はすべて string で来るので便利)。本番ならアプリ起動時に Env.parse(process.env) で fail-fast。
よく詰まる落とし穴
.partial()は浅い — ネストしたオブジェクトの partial には.deepPartial()か手動で書く.passthrough()を忘れて余計なキーを drop してしまう — デフォルトは strip。余計なキーを許容したい時は.passthrough()か.strict()(余計なキーで fail)を選ぶz.coerce.boolean()は文字列 “false” を true にする(空でない文字列は truthy)。bool 文字列のパースは自前で transform するか、z.enum(["true", "false"]).transform(v => v === "true")のような明示的処理が必要- 再帰スキーマは
z.lazy()が必要 —z.object({ children: Tree.array() })のような自己参照はz.lazy(() => Tree)で囲む - TypeScript の型推論が遅くなる — ネスト深いスキーマで IDE が重くなる。スキーマを 小さく分割 して
extendで組み合わせる
性能と用途の境目
- API 境界 / form / env: zod は適切。型推論 + ランタイム検証の合わせ技
- 数百万レコードの hot path: zod の
parseがボトルネックになる場合がある。検証はサンプリングか、AOT コンパイルされた検証(typia / valibot 等)を検討 - OpenAPI / JSON Schema からの生成: zod 単体では不可。
zod-to-json-schema等で変換するか、最初から JSON Schema ベースのライブラリ(ajv)を選ぶ
向く / 向かないケース
- 向く: TypeScript プロジェクトの型境界全般、フォーム検証(react-hook-form と組み合わせ)、env validation、LLM の構造化出力検証
- 向かない: 1ms 以下が求められる極端な hot path、Node 以外のランタイム(deno / bun は OK)、JSON Schema が一次資料の環境
See also(任意、単独でも完結)
本 skill は zod の実装に特化しており、単独で完結します。記事化する場合は本リポの src/content/methodology/01-editorial-principles を参照。
参考
- 公式: https://zod.dev
- npm:
zod - 主要なエコシステム: react-hook-form / Astro Content Collection / tRPC / next-safe-action