Zod完全実践ガイド〜スキーマ・型推論・React Hook Form/Next.js Server Actions連携【2026年版】〜

Zod は TypeScript ファースト設計のスキーマ定義 & バリデーションライブラリです。「スキーマを 1 度書けば、TypeScript の型・実行時バリデータ・型推論が同時に得られる」という特性により、API レスポンス検証・フォーム入力検証・環境変数検証など、「TypeScript の型システムでは守れない “実行時の境界”」を一手に引き受けます。

本記事では TypeScript 5.x / Zod v3.x 準拠で コピペで動く 40 以上のコードサンプル を通して、基本構文 → 高度なバリデーション → React Hook Form / Next.js Server Actions / tRPC / Hono / ts-rest 連携 → valibot・Yup との比較 → パフォーマンス考察まで、Zod 単体としての実践知見を完全網羅します。本記事は「バリデーションスキーマ」観点に特化しているため、React Hook Form 完全ガイド(フォームライブラリ観点)、およびTypeScript 型ガード完全ガイド(コンパイル時 narrowing 観点)と相互補完的に読むと、TypeScript アプリケーションの「型の壁・実行時の壁」を一気通貫で押さえることができます。

  1. 1. Zod の概念とインストール
    1. 1.1 インストール
    2. 1.2 インポートと最小サンプル
  2. 2. プリミティブスキーマと基本制約
    1. 2.1 string / number / boolean
    2. 2.2 文字列の組み込みフォーマット
    3. 2.3 length / min / max
    4. 2.4 正規表現バリデーション
  3. 3. オブジェクト・配列・タプル
    1. 3.1 z.object() 基本
    2. 3.2 strict / passthrough / catchall
    3. 3.3 partial / pick / omit / extend / merge
    4. 3.4 z.array() / z.tuple()
  4. 4. 列挙・リテラル・union
    1. 4.1 z.enum と z.nativeEnum
    2. 4.2 z.literal と z.union
    3. 4.3 z.discriminatedUnion(高速判別)
  5. 5. optional / nullable / default / catch
    1. 5.1 optional と nullable
    2. 5.2 default(デフォルト値)
    3. 5.3 catch(失敗時のフォールバック)
  6. 6. .parse vs .safeParse の違い
    1. 6.1 .parse は失敗で例外を投げる
    2. 6.2 .safeParse は結果オブジェクトを返す(throw しない)
    3. 6.3 async 版
  7. 7. カスタムバリデーション(.refine / .superRefine)
    1. 7.1 .refine(単一バリデーション)
    2. 7.2 .superRefine(複数 issue を同時に追加)
    3. 7.3 クロスフィールドバリデーション
  8. 8. .transform / .pipe / preprocess
    1. 8.1 .transform(値を変換しつつ型を変える)
    2. 8.2 .pipe(変換結果を別スキーマで再検証)
    3. 8.3 z.preprocess(parse 前に変換)
  9. 9. 型推論: z.infer / z.input / z.output
    1. 9.1 z.infer<typeof schema> が基本
    2. 9.2 transform を挟むと input / output が異なる
  10. 10. 再帰スキーマと z.lazy
    1. 10.1 木構造を表現する
    2. 10.2 z.lazy 利用時の注意
  11. 11. 実践 1: API レスポンス検証で fetch を型安全化
    1. 11.1 fetch + zod の安全ラッパ
    2. 11.2 汎用 fetch + zod ヘルパ
  12. 12. 実践 2: 環境変数の検証(env.ts パターン)
    1. 12.1 process.env をスキーマで検証
  13. 13. 実践 3: React Hook Form + zodResolver 連携
    1. 13.1 セットアップ
    2. 13.2 サインアップフォーム例
  14. 14. 実践 4: Next.js Server Action での safeParseAsync
    1. 14.1 Server Action 側
    2. 14.2 クライアント側(useActionState)
  15. 15. 実践 5: tRPC との連携
    1. 15.1 tRPC ルーター定義
    2. 15.2 クライアント側
  16. 16. 実践 6: Hono + zod-validator
    1. 16.1 Hono ルートで Zod を使う
  17. 17. 実践 7: ts-rest との連携
    1. 17.1 契約定義
  18. 18. エラーメッセージカスタマイズ
    1. 18.1 スキーマ単位のメッセージ
    2. 18.2 グローバルなエラーマップ(多言語化)
    3. 18.3 個別フィールドの errorMap
  19. 19. エラー解析のテクニック
    1. 19.1 .format() / .flatten()
    2. 19.2 path で UI 表示位置を特定
  20. 20. パフォーマンスと最適化
    1. 20.1 スキーマはモジュール先頭で 1 回だけ作る
    2. 20.2 discriminatedUnion を使う
    3. 20.3 信頼できる境界では parse を省略
  21. 21. Zod / Yup / valibot の比較
    1. 21.1 同等スキーマでの記述比較
    2. 21.2 採用判断の指針
  22. 22. 実務での Tips とアンチパターン
    1. 22.1 1 スキーマ → 複数派生のパターン
    2. 22.2 .brand() で nominal typing
    3. 22.3 アンチパターン: any や unknown で逃げる
  23. 23. まとめ
    1. 関連学習教材

1. Zod の概念とインストール

Zod は単なる「JSON Schema 互換ライブラリ」ではなく、TypeScript の型推論を最大限活用する設計が特徴です。スキーマから z.infer<typeof schema> で型を抽出できるため、「型定義 / バリデーション / パース」を Single Source of Truth(単一の真実の源) として一元管理できます。

1.1 インストール

# npm
npm install zod

# pnpm
pnpm add zod

# yarn
yarn add zod

# bun
bun add zod

TypeScript 側は tsconfig.json"strict": true を有効にしておきます(詳しくは tsconfig.json 完全ガイド 参照)。

1.2 インポートと最小サンプル

import { z } from "zod";

// スキーマ定義
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// スキーマから TypeScript 型を導出
type User = z.infer<typeof UserSchema>;
// type User = { id: number; name: string; email: string; }

// 実データを検証
const data: unknown = { id: 1, name: "Alice", email: "alice@example.com" };
const user = UserSchema.parse(data); // 失敗時は例外
console.log(user.email);

このように 1 つのスキーマから「型 (User)」「実行時バリデータ (.parse)」「ドキュメント (定義そのもの)」が同時に得られるのが Zod の最大価値です。

2. プリミティブスキーマと基本制約

2.1 string / number / boolean

import { z } from "zod";

const StrSchema  = z.string();
const NumSchema  = z.number();
const BoolSchema = z.boolean();
const NullSchema = z.null();
const UndefSchema = z.undefined();
const AnySchema  = z.any();      // 何でも通る(非推奨)
const UnknownSchema = z.unknown(); // 型は unknown のまま

StrSchema.parse("hello");   // OK
NumSchema.parse(42);        // OK
BoolSchema.parse(true);     // OK
// NumSchema.parse("42");   // ZodError: Expected number, received string

2.2 文字列の組み込みフォーマット

// よく使うフォーマット
const Email  = z.string().email("メール形式が不正です");
const Url    = z.string().url();
const Uuid   = z.string().uuid();
const Cuid   = z.string().cuid();
const Cuid2  = z.string().cuid2();
const Ulid   = z.string().ulid();
const Ip     = z.string().ip();              // v4 / v6 両対応
const Ipv4   = z.string().ip({ version: "v4" });
const DateStr = z.string().datetime();        // ISO 8601
const TimeStr = z.string().time();
const DateOnly = z.string().date();           // YYYY-MM-DD
const Emoji  = z.string().emoji();
const Nano   = z.string().nanoid();
const Base64 = z.string().base64();

Email.parse("a@b.com");          // OK
// Email.parse("not-email");     // ZodError
Url.parse("https://example.com"); // OK

2.3 length / min / max

const Username = z
  .string()
  .min(3, "3 文字以上で入力してください")
  .max(20, "20 文字以内で入力してください");

const Age = z
  .number()
  .int()             // 整数のみ
  .min(0)
  .max(150)
  .nonnegative();    // 0 以上

const Tags = z.array(z.string()).length(3); // ちょうど 3 件

Username.parse("alice");  // OK
// Username.parse("ab"); // 3 文字以上で入力してください
Age.parse(30);            // OK

2.4 正規表現バリデーション

// 日本の郵便番号(7 桁ハイフンあり)
const Zip = z.string().regex(/^d{3}-d{4}$/, "郵便番号は 123-4567 形式で入力してください");

// 半角英数のみ
const Slug = z.string().regex(/^[a-z0-9-]+$/);

// 開始一致
const Hex = z.string().startsWith("#").length(7);
// 終了一致
const PngFile = z.string().endsWith(".png");

Zip.parse("123-4567");  // OK
// Zip.parse("1234567"); // ZodError

3. オブジェクト・配列・タプル

3.1 z.object() 基本

const ProductSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  price: z.number().nonnegative(),
  inStock: z.boolean(),
});

type Product = z.infer<typeof ProductSchema>;

const p = ProductSchema.parse({
  id: 1,
  name: "Keyboard",
  price: 12800,
  inStock: true,
});

3.2 strict / passthrough / catchall

// strict: 未定義キーがあればエラー
const Strict = z.object({ a: z.string() }).strict();
// Strict.parse({ a: "x", b: 1 }); // ZodError: unrecognized keys

// passthrough: 未定義キーをそのまま残す
const Pass = z.object({ a: z.string() }).passthrough();
Pass.parse({ a: "x", b: 1 }); // { a: "x", b: 1 }

// catchall: 未定義キーの型を指定
const Catch = z.object({ a: z.string() }).catchall(z.number());
Catch.parse({ a: "x", b: 1, c: 2 }); // OK
// Catch.parse({ a: "x", b: "no" }); // ZodError

3.3 partial / pick / omit / extend / merge

const Base = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().int(),
});

// すべて optional に
const Patch = Base.partial();
// 一部だけ optional に
const Patch2 = Base.partial({ age: true });

// 抜き出し
const NameOnly = Base.pick({ name: true });
// 除外
const NoId = Base.omit({ id: true });

// 拡張
const Extended = Base.extend({
  createdAt: z.string().datetime(),
});

// マージ
const Address = z.object({ city: z.string() });
const UserWithAddress = Base.merge(Address);

3.4 z.array() / z.tuple()

// 配列
const Numbers = z.array(z.number());
Numbers.parse([1, 2, 3]); // OK

// 制約付き配列
const Tags = z.array(z.string()).min(1).max(5).nonempty();

// タプル(固定長・固定型)
const Point = z.tuple([z.number(), z.number()]);
Point.parse([10, 20]); // OK
// Point.parse([10]);   // ZodError

// 末尾可変長タプル
const Path = z.tuple([z.string()]).rest(z.string());
Path.parse(["root", "users", "1"]); // OK

// Set / Map
const TagSet = z.set(z.string());
const ScoreMap = z.map(z.string(), z.number());

4. 列挙・リテラル・union

4.1 z.enum と z.nativeEnum

// z.enum: 文字列リテラルの union を直接定義
const RoleSchema = z.enum(["admin", "editor", "viewer"]);
type Role = z.infer<typeof RoleSchema>; // "admin" | "editor" | "viewer"

RoleSchema.parse("admin"); // OK
// RoleSchema.parse("root"); // ZodError

// 取り出し
RoleSchema.options;          // readonly ["admin", "editor", "viewer"]
RoleSchema.enum.admin;       // "admin"

// z.nativeEnum: TS の enum と連動
enum Status { Draft = "draft", Published = "published" }
const StatusSchema = z.nativeEnum(Status);
StatusSchema.parse(Status.Draft); // OK

4.2 z.literal と z.union

// リテラル
const Yes = z.literal("yes");
const Forty = z.literal(42);
const True = z.literal(true);

// union
const StringOrNumber = z.union([z.string(), z.number()]);
StringOrNumber.parse("hello"); // OK
StringOrNumber.parse(42);      // OK

// 省略形: z.or()
const A = z.string().or(z.number());

// 文字列リテラル union(z.enum と等価だが、より柔軟に書ける)
const Method = z.union([
  z.literal("GET"),
  z.literal("POST"),
  z.literal("PUT"),
  z.literal("DELETE"),
]);

4.3 z.discriminatedUnion(高速判別)

// イベント型を type フィールドで判別
const EventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
  z.object({ type: z.literal("key"), code: z.string() }),
  z.object({ type: z.literal("scroll"), delta: z.number() }),
]);

type Event = z.infer<typeof EventSchema>;

const e = EventSchema.parse({ type: "click", x: 10, y: 20 });
if (e.type === "click") {
  // ここでは x, y にアクセス可能(narrowing が効く)
  console.log(e.x, e.y);
}

通常の z.union は全候補に対して順番にパースを試みますが、discriminatedUnion は判別キーで一発で分岐するため、エラー精度・パフォーマンスとも大幅に優れています(型ガードの考え方と本質的に同じです)。

5. optional / nullable / default / catch

5.1 optional と nullable

const Form = z.object({
  name: z.string(),
  // optional: undefined を許可(キー自体が無くてもよい)
  nickname: z.string().optional(),
  // nullable: null を許可
  bio: z.string().nullable(),
  // 両方許可
  url: z.string().url().nullish(),
});

type FormT = z.infer<typeof Form>;
// {
//   name: string;
//   nickname?: string | undefined;
//   bio: string | null;
//   url?: string | null | undefined;
// }

5.2 default(デフォルト値)

const Config = z.object({
  theme: z.enum(["light", "dark"]).default("light"),
  pageSize: z.number().int().default(20),
  enabled: z.boolean().default(true),
});

const c = Config.parse({});
// { theme: "light", pageSize: 20, enabled: true }

5.3 catch(失敗時のフォールバック)

// バリデーション失敗時に既定値で復活
const Safe = z.number().catch(0);
Safe.parse("abc"); // 0(エラーにならず 0 を返す)
Safe.parse(42);    // 42

// オブジェクトでも使える
const SafeUser = z.object({
  id: z.number(),
  role: z.enum(["admin", "user"]).catch("user"),
});
SafeUser.parse({ id: 1, role: "guest" }); // { id: 1, role: "user" }

6. .parse vs .safeParse の違い

6.1 .parse は失敗で例外を投げる

const Schema = z.object({ name: z.string() });

try {
  const user = Schema.parse({ name: 123 });
} catch (err) {
  if (err instanceof z.ZodError) {
    console.error(err.issues);
    // [{ code: 'invalid_type', expected: 'string', received: 'number', path: ['name'], message: '...' }]
  }
}

6.2 .safeParse は結果オブジェクトを返す(throw しない)

const result = Schema.safeParse({ name: 123 });

if (result.success) {
  // result.data に型安全な値
  console.log(result.data.name);
} else {
  // result.error は ZodError
  console.error(result.error.flatten());
  // { formErrors: [], fieldErrors: { name: ["Expected string, received number"] } }
}

6.3 async 版

// .refine などで非同期処理を含む場合は parseAsync / safeParseAsync を使う
const UniqueEmail = z.string().email().refine(
  async (email) => {
    // 例: DB 重複チェック
    const exists = await checkExists(email);
    return !exists;
  },
  { message: "そのメールアドレスはすでに登録されています" }
);

const r = await UniqueEmail.safeParseAsync("a@b.com");
async function checkExists(_email: string): Promise<boolean> { return false; }

サーバー側・フォーム送信側・Next.js Server Action では safeParse(または safeParseAsync)を使う方が、例外ハンドリングが不要で扱いやすいです。

7. カスタムバリデーション(.refine / .superRefine)

7.1 .refine(単一バリデーション)

// 偶数のみ
const Even = z.number().refine((n) => n % 2 === 0, {
  message: "偶数を入力してください",
});

Even.parse(4);  // OK
// Even.parse(3); // 偶数を入力してください

// 文字列の独自ルール
const NotAdmin = z.string().refine((s) => s !== "admin", {
  message: "admin は予約語のため使えません",
});

7.2 .superRefine(複数 issue を同時に追加)

const Password = z
  .string()
  .superRefine((val, ctx) => {
    if (val.length < 8) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "8 文字以上にしてください",
      });
    }
    if (!/[A-Z]/.test(val)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "大文字を 1 文字以上含めてください",
      });
    }
    if (!/[0-9]/.test(val)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "数字を 1 文字以上含めてください",
      });
    }
    if (!/[!-/:-@¥[-`{-~]/.test(val)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "記号を 1 文字以上含めてください",
      });
    }
  });

const r = Password.safeParse("abc");
// r.error.issues に 4 件の issue が並ぶ

7.3 クロスフィールドバリデーション

// password と passwordConfirm が一致するか
const Signup = z
  .object({
    password: z.string().min(8),
    passwordConfirm: z.string(),
  })
  .refine((data) => data.password === data.passwordConfirm, {
    message: "パスワードが一致しません",
    path: ["passwordConfirm"], // エラーをこのフィールドに紐付ける
  });

const r = Signup.safeParse({ password: "abcd1234", passwordConfirm: "xxxx" });
// r.error.flatten().fieldErrors.passwordConfirm に「パスワードが一致しません」

8. .transform / .pipe / preprocess

8.1 .transform(値を変換しつつ型を変える)

// 文字列 → 数値
const StrToNum = z.string().transform((val) => Number(val));

const n = StrToNum.parse("42"); // n: number = 42

// オブジェクトを別形に変換
const NameSchema = z.object({ first: z.string(), last: z.string() })
  .transform(({ first, last }) => ({
    fullName: `${first} ${last}`,
    initials: `${first[0]}.${last[0]}.`,
  }));

NameSchema.parse({ first: "Taro", last: "Yamada" });
// { fullName: "Taro Yamada", initials: "T.Y." }

8.2 .pipe(変換結果を別スキーマで再検証)

// "42" のような文字列を、数値化したうえで「1〜100」検証
const NumString = z
  .string()
  .transform((val) => Number(val))
  .pipe(z.number().int().min(1).max(100));

NumString.parse("42");      // 42
// NumString.parse("999");  // ZodError(transform 後の値が 100 を超える)
// NumString.parse("abc");  // ZodError(transform 後が NaN)

8.3 z.preprocess(parse 前に変換)

// "true" / "false" 文字列を boolean として扱う
const Bool = z.preprocess(
  (val) => (typeof val === "string" ? val === "true" : val),
  z.boolean()
);

Bool.parse("true");  // true
Bool.parse(false);   // false

// 日付文字列を Date オブジェクト化
const DateLike = z.preprocess((arg) => {
  if (typeof arg === "string" || arg instanceof Date) return new Date(arg);
  return arg;
}, z.date());

DateLike.parse("2026-05-27"); // Date オブジェクト

9. 型推論: z.infer / z.input / z.output

9.1 z.infer<typeof schema> が基本

const Schema = z.object({ name: z.string(), age: z.number() });
type Schema = z.infer<typeof Schema>;
// { name: string; age: number }

9.2 transform を挟むと input / output が異なる

const StrToNum = z.string().transform((s) => Number(s));

// 入力(parse に渡す型)
type InType  = z.input<typeof StrToNum>;  // string
// 出力(parse 後の型)
type OutType = z.output<typeof StrToNum>; // number

// z.infer は z.output と同じ
type InferType = z.infer<typeof StrToNum>; // number

フォームの送信値(input)と、サーバーで扱う値(output)で型が異なるケースでは z.input / z.output の使い分けが重要になります。

10. 再帰スキーマと z.lazy

10.1 木構造を表現する

// コメントスレッドのような再帰構造
type Category = {
  name: string;
  children: Category[];
};

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    children: z.array(CategorySchema),
  })
);

const tree = CategorySchema.parse({
  name: "root",
  children: [
    { name: "a", children: [] },
    { name: "b", children: [{ name: "b-1", children: [] }] },
  ],
});

10.2 z.lazy 利用時の注意

TypeScript は再帰型の自動推論ができないため、z.ZodType<Category> のように型注釈を明示する必要があります。これは Zod の制約というより TS の制約です(詳しくは Conditional Types ガイドでも触れた通り、再帰型の推論は深さ上限が存在します)。

11. 実践 1: API レスポンス検証で fetch を型安全化

11.1 fetch + zod の安全ラッパ

import { z } from "zod";

// API レスポンスのスキーマ
const PostSchema = z.object({
  userId: z.number(),
  id: z.number(),
  title: z.string(),
  body: z.string(),
});
const PostsSchema = z.array(PostSchema);
export type Post = z.infer<typeof PostSchema>;

export async function fetchPosts(): Promise<Post[]> {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const json: unknown = await res.json();
  return PostsSchema.parse(json); // ここで実行時バリデーション
}

11.2 汎用 fetch + zod ヘルパ

// どんなスキーマでも安全に fetch するヘルパ
export async function fetchJson<T extends z.ZodTypeAny>(
  url: string,
  schema: T,
  init?: RequestInit
): Promise<z.infer<T>> {
  const res = await fetch(url, init);
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
  const json: unknown = await res.json();
  const parsed = schema.safeParse(json);
  if (!parsed.success) {
    console.error("Response validation failed", parsed.error.issues);
    throw new Error("API レスポンス形式が想定と異なります");
  }
  return parsed.data;
}

// 利用例
const posts = await fetchJson(
  "https://jsonplaceholder.typicode.com/posts",
  PostsSchema
);

12. 実践 2: 環境変数の検証(env.ts パターン)

12.1 process.env をスキーマで検証

// src/env.ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT_SECRET は 32 文字以上必須"),
  ENABLE_LOG: z
    .enum(["true", "false"])
    .default("false")
    .transform((v) => v === "true"),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("環境変数エラー:", parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;
// env.PORT は number, env.ENABLE_LOG は boolean に変換済み

z.coerce.number() は内部的に Number() を通すので、文字列の環境変数を数値に変換できます。アプリ起動時に厳格バリデーションすることで、本番で「設定漏れ」が原因の障害を防げます。

13. 実践 3: React Hook Form + zodResolver 連携

13.1 セットアップ

npm install react-hook-form @hookform/resolvers zod

13.2 サインアップフォーム例

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const SignupSchema = z
  .object({
    email: z.string().email("メール形式が不正です"),
    password: z.string().min(8, "8 文字以上"),
    passwordConfirm: z.string(),
    agree: z.literal(true, {
      errorMap: () => ({ message: "利用規約への同意が必要です" }),
    }),
  })
  .refine((d) => d.password === d.passwordConfirm, {
    message: "パスワードが一致しません",
    path: ["passwordConfirm"],
  });

type SignupForm = z.infer<typeof SignupSchema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupForm>({
    resolver: zodResolver(SignupSchema),
    mode: "onBlur",
  });

  const onSubmit = async (data: SignupForm) => {
    await fetch("/api/signup", { method: "POST", body: JSON.stringify(data) });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register("passwordConfirm")} />
      {errors.passwordConfirm && <p>{errors.passwordConfirm.message}</p>}

      <label>
        <input type="checkbox" {...register("agree")} /> 利用規約に同意
      </label>
      {errors.agree && <p>{errors.agree.message}</p>}

      <button disabled={isSubmitting}>送信</button>
    </form>
  );
}

React Hook Form 単体の使い方は React Hook Form 完全ガイド を参照してください。Zod は「スキーマの真実の源」、RHF は「フォーム状態管理」と役割分担が明確で、組み合わせで真価を発揮します。

14. 実践 4: Next.js Server Action での safeParseAsync

14.1 Server Action 側

// app/actions/createPost.ts
"use server";
import { z } from "zod";

const CreatePostInput = z.object({
  title: z.string().min(1).max(80),
  body: z.string().min(10).max(10_000),
  tags: z.array(z.string().min(1).max(20)).max(5),
});

export type CreatePostState = {
  ok: boolean;
  errors?: Record<string, string[]>;
  message?: string;
};

export async function createPost(
  _prev: CreatePostState,
  formData: FormData
): Promise<CreatePostState> {
  const raw = {
    title: formData.get("title"),
    body: formData.get("body"),
    tags: formData.getAll("tags"),
  };

  const parsed = await CreatePostInput.safeParseAsync(raw);
  if (!parsed.success) {
    return {
      ok: false,
      errors: parsed.error.flatten().fieldErrors,
    };
  }

  // 検証済データで DB へ保存
  // await db.posts.create({ data: parsed.data });
  return { ok: true, message: "保存しました" };
}

14.2 クライアント側(useActionState)

"use client";
import { useActionState } from "react";
import { createPost, type CreatePostState } from "./actions/createPost";

const initialState: CreatePostState = { ok: false };

export function PostForm() {
  const [state, formAction, pending] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" />
      {state.errors?.title?.map((m) => <p key={m}>{m}</p>)}

      <textarea name="body" />
      {state.errors?.body?.map((m) => <p key={m}>{m}</p>)}

      <button disabled={pending}>保存</button>
      {state.ok && <p>{state.message}</p>}
    </form>
  );
}

15. 実践 5: tRPC との連携

tRPC は入力 / 出力スキーマとして Zod を採用しています。スキーマを 1 回書くだけで、サーバー検証・クライアント型安全・ドキュメントが揃います。

15.1 tRPC ルーター定義

import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;

export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.number().int().positive() }))
    .output(
      z.object({ id: z.number(), name: z.string(), email: z.string().email() })
    )
    .query(async ({ input }) => {
      // input は { id: number } として型安全
      return { id: input.id, name: "Alice", email: "alice@example.com" };
    }),

  createUser: publicProcedure
    .input(
      z.object({
        name: z.string().min(1),
        email: z.string().email(),
      })
    )
    .mutation(async ({ input }) => {
      // input は完全に検証済み
      return { id: 1, ...input };
    }),
});

export type AppRouter = typeof appRouter;

15.2 クライアント側

import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./server";

const trpc = createTRPCProxyClient<AppRouter>({
  links: [httpBatchLink({ url: "http://localhost:3000/trpc" })],
});

// 型安全:input が { id: number } なのが分かっている
const user = await trpc.getUser.query({ id: 1 });

16. 実践 6: Hono + zod-validator

16.1 Hono ルートで Zod を使う

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

const app = new Hono();

const CreateUser = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

app.post("/users", zValidator("json", CreateUser), (c) => {
  // c.req.valid("json") は型安全
  const data = c.req.valid("json");
  return c.json({ ok: true, user: data });
});

// クエリストリング検証
app.get(
  "/users",
  zValidator(
    "query",
    z.object({ q: z.string().optional(), page: z.coerce.number().default(1) })
  ),
  (c) => {
    const { q, page } = c.req.valid("query");
    return c.json({ q, page });
  }
);

export default app;

17. 実践 7: ts-rest との連携

17.1 契約定義

import { initContract } from "@ts-rest/core";
import { z } from "zod";

const c = initContract();

const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  body: z.string(),
});

export const contract = c.router({
  getPost: {
    method: "GET",
    path: "/posts/:id",
    pathParams: z.object({ id: z.string() }),
    responses: {
      200: PostSchema,
      404: z.object({ message: z.string() }),
    },
  },
  createPost: {
    method: "POST",
    path: "/posts",
    body: z.object({ title: z.string().min(1), body: z.string().min(1) }),
    responses: {
      201: PostSchema,
    },
  },
});

ts-rest はサーバ・クライアント両方でこの契約を共有するため、Zod スキーマがそのまま「API ドキュメント+ランタイム検証+型」として機能します。

18. エラーメッセージカスタマイズ

18.1 スキーマ単位のメッセージ

const Schema = z.object({
  name: z.string({
    required_error: "名前は必須です",
    invalid_type_error: "名前は文字列で入力してください",
  }).min(1, "名前は 1 文字以上"),
  age: z.number({
    required_error: "年齢は必須です",
    invalid_type_error: "年齢は数値で入力してください",
  }),
});

18.2 グローバルなエラーマップ(多言語化)

// 日本語エラーマップを設定
const jaErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    if (issue.received === "undefined") return { message: "必須項目です" };
    return { message: `${issue.expected} 型で入力してください` };
  }
  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === "string")
      return { message: `${issue.minimum} 文字以上で入力してください` };
    if (issue.type === "number")
      return { message: `${issue.minimum} 以上を入力してください` };
  }
  if (issue.code === z.ZodIssueCode.invalid_string) {
    if (issue.validation === "email")
      return { message: "メール形式が不正です" };
    if (issue.validation === "url") return { message: "URL 形式が不正です" };
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(jaErrorMap);

// 以降、すべてのスキーマで日本語メッセージが出る
z.string().email().parse("not-email"); // "メール形式が不正です"

18.3 個別フィールドの errorMap

const StrictNumber = z.number({
  errorMap: (issue, ctx) => {
    if (issue.code === "invalid_type") {
      return { message: "数字を入力してください" };
    }
    return { message: ctx.defaultError };
  },
});

19. エラー解析のテクニック

19.1 .format() / .flatten()

const Schema = z.object({
  user: z.object({ name: z.string(), age: z.number() }),
  tags: z.array(z.string()),
});

const r = Schema.safeParse({
  user: { name: 123, age: "x" },
  tags: ["a", 2, "c"],
});

if (!r.success) {
  // 木構造で取り出す(ネスト対応)
  console.log(r.error.format());
  /*
  {
    _errors: [],
    user: {
      _errors: [],
      name: { _errors: ["Expected string, received number"] },
      age:  { _errors: ["Expected number, received string"] },
    },
    tags: {
      "1": { _errors: ["Expected string, received number"] },
      _errors: [],
    },
  }
  */

  // 平坦化(フォーム向け)
  console.log(r.error.flatten());
  /*
  {
    formErrors: [],
    fieldErrors: {
      user: ["..."], tags: ["..."]
    }
  }
  */
}

19.2 path で UI 表示位置を特定

// 各 issue は path: (string | number)[] を持つ
r.error?.issues.forEach((i) => {
  console.log(i.path.join("."), i.message);
});
// "user.name", "Expected string, received number"
// "user.age",  "Expected number, received string"
// "tags.1",    "Expected string, received number"

20. パフォーマンスと最適化

20.1 スキーマはモジュール先頭で 1 回だけ作る

// ❌ 関数内で毎回生成(無駄に重い)
function parseUser(data: unknown) {
  const Schema = z.object({ id: z.number(), name: z.string() }); // 毎回生成
  return Schema.parse(data);
}

// ✅ モジュールスコープで 1 回だけ
const UserSchema = z.object({ id: z.number(), name: z.string() });
function parseUserFast(data: unknown) {
  return UserSchema.parse(data);
}

20.2 discriminatedUnion を使う

// 通常の union: 全候補に順番に parse を試す(O(n))
const Slow = z.union([
  z.object({ type: z.literal("a"), a: z.number() }),
  z.object({ type: z.literal("b"), b: z.string() }),
  z.object({ type: z.literal("c"), c: z.boolean() }),
]);

// discriminatedUnion: type を見て即分岐(O(1))
const Fast = z.discriminatedUnion("type", [
  z.object({ type: z.literal("a"), a: z.number() }),
  z.object({ type: z.literal("b"), b: z.string() }),
  z.object({ type: z.literal("c"), c: z.boolean() }),
]);

20.3 信頼できる境界では parse を省略

すべてのオブジェクトに parse をかけるのは過剰です。原則として「外部から入ってくる境界(API レスポンス・フォーム入力・環境変数・WebSocket・LocalStorage)」だけバリデートし、内部処理では TypeScript の型だけに頼るのが、パフォーマンス・コード可読性の両面で正解です。

21. Zod / Yup / valibot の比較

21.1 同等スキーマでの記述比較

// Zod
import { z } from "zod";
const ZUser = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0),
});
type ZUser = z.infer<typeof ZUser>;
// Yup
import * as yup from "yup";
const YUser = yup.object({
  name: yup.string().required().min(1),
  email: yup.string().required().email(),
  age: yup.number().required().integer().min(0),
});
type YUser = yup.InferType<typeof YUser>;
// valibot
import * as v from "valibot";
const VUser = v.object({
  name: v.pipe(v.string(), v.minLength(1)),
  email: v.pipe(v.string(), v.email()),
  age: v.pipe(v.number(), v.integer(), v.minValue(0)),
});
type VUser = v.InferOutput<typeof VUser>;

21.2 採用判断の指針

  • Zod: エコシステム最大級。tRPC・Hono・ts-rest・@hookform/resolvers が標準対応。ドキュメント・記事が圧倒的に多く、人材確保・学習コストの面で最も無難。
  • valibot: モジュールベースで Tree-shake が効きやすく、バンドルサイズが Zod の 1/10 程度になりうる。ブラウザバンドルの軽量化が必須なケースで強力。
  • Yup: Formik 時代からの歴史があり、既存資産・チームスキルがある場合の選択肢。新規プロジェクトでは Zod 優位。

新規プロジェクトかつ「バンドルサイズが特別シビア」でないなら Zod 一択で問題ありません。本記事の知識はそのまま React Hook Form(A15)・Type Guards(B05)・型推論(50)と組み合わせ可能です。

22. 実務での Tips とアンチパターン

22.1 1 スキーマ → 複数派生のパターン

// マスタースキーマ
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  passwordHash: z.string(),
  createdAt: z.string().datetime(),
});

// 公開用(機密フィールドを除外)
export const PublicUserSchema = UserSchema.omit({ passwordHash: true });

// 作成用(id / createdAt を除外、生パスワードを追加)
export const CreateUserSchema = UserSchema.omit({
  id: true,
  passwordHash: true,
  createdAt: true,
}).extend({ password: z.string().min(8) });

// 更新用(すべて optional)
export const UpdateUserSchema = CreateUserSchema.partial();

22.2 .brand() で nominal typing

// id 同士の取り違えを型レベルで防ぐ
const UserId = z.string().uuid().brand<"UserId">();
const PostId = z.string().uuid().brand<"PostId">();

type UserId = z.infer<typeof UserId>;
type PostId = z.infer<typeof PostId>;

function getUser(id: UserId) { /* ... */ }
const uid = UserId.parse("11111111-1111-1111-1111-111111111111");
const pid = PostId.parse("22222222-2222-2222-2222-222222222222");
getUser(uid);   // OK
// getUser(pid); // Error: PostId は UserId に代入できない

22.3 アンチパターン: any や unknown で逃げる

// ❌ unknown のまま使ってしまう
const Bad = z.object({ payload: z.unknown() }); // 中身を後で as Cast する羽目になる

// ✅ できるだけ厳密に定義する。本当に未知ならば、別スキーマと .pipe で段階検証
const Good = z.object({
  payload: z.unknown().pipe(
    z.union([
      z.object({ kind: z.literal("a"), value: z.string() }),
      z.object({ kind: z.literal("b"), value: z.number() }),
    ])
  ),
});

23. まとめ

Zod は 「TypeScript の型と実行時バリデーションを 1 つのスキーマで統一する」 ためのほぼ唯一無二の選択肢です。本記事で扱った範囲を実装に取り入れれば、以下が一気に解決します。

  • 外部 API レスポンスの「型は通るが実体が違う」事故を防げる
  • フォーム入力・FormData の検証ロジックがスキーマ 1 本に集約できる
  • 環境変数の設定漏れがアプリ起動時に検知できる
  • tRPC / Hono / ts-rest / RHF / Next.js Server Actions のすべてで同じスキーマを再利用できる
  • discriminated union・brand・partial / pick / omit などで複雑なドメインモデルも整理できる

関連記事として、フォーム状態管理側を扱う React Hook Form 完全ガイド、コンパイル時の narrowing 観点を整理した TypeScript 型ガード完全ガイド、ベースとなる ジェネリクス型推論Mapped Types を順に押さえると、「Zod スキーマを駆動する TypeScript 側の地力」も底上げできます。

TypeScript と Zod を組み合わせた「型安全 + 実行時安全」なアプリ設計を、現場で 1 つでも多く実装に落とし込んでみてください。

関連学習教材

体系的に TypeScript × バリデーション設計を学びたい場合は、現役エンジニアのメンタリングが受けられる以下のスクールが定評です。フロントエンドコースでは Zod を含むモダンスタック(Next.js / React Hook Form / tRPC)が標準カリキュラムに組み込まれています。

  • テックアカデミー(TechAcademy): TypeScript フロントエンドコースで React + 型システムを実践演習。
  • 侍エンジニア: マンツーマンで「Zod を含む実務スタック」のオーダーメイド学習が可能。
  • DMM WEBCAMP: 短期間集中で TypeScript / React 実装力を養成。
  • レバテックカレッジ / レバテックフリーランス: 学習〜キャリアまで一気通貫で支援。Zod を含むモダンスタック案件多数。

コメント

タイトルとURLをコピーしました