tech-book-labs
libraries

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 点

  1. parse() は throw、safeParse() は Result 型を返す。境界で throw が許容できる文脈(API handler の冒頭)以外は safeParse() 一択
  2. z.infer<typeof schema> は schema 定義の に書く。schema を as const で凍結する必要は無い(zod 自体が型を保持する)
  3. .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()undefinedT | undefinedキーが存在しないかもしれない
.nullable()nullT | nullキーは必ずあるが null 許容
.default(v)undefinedT(必ず値あり)未指定時にデフォルト値で埋める

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