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. Zod の概念とインストール
- 2. プリミティブスキーマと基本制約
- 3. オブジェクト・配列・タプル
- 4. 列挙・リテラル・union
- 5. optional / nullable / default / catch
- 6. .parse vs .safeParse の違い
- 7. カスタムバリデーション(.refine / .superRefine)
- 8. .transform / .pipe / preprocess
- 9. 型推論: z.infer / z.input / z.output
- 10. 再帰スキーマと z.lazy
- 11. 実践 1: API レスポンス検証で fetch を型安全化
- 12. 実践 2: 環境変数の検証(env.ts パターン)
- 13. 実践 3: React Hook Form + zodResolver 連携
- 14. 実践 4: Next.js Server Action での safeParseAsync
- 15. 実践 5: tRPC との連携
- 16. 実践 6: Hono + zod-validator
- 17. 実践 7: ts-rest との連携
- 18. エラーメッセージカスタマイズ
- 19. エラー解析のテクニック
- 20. パフォーマンスと最適化
- 21. Zod / Yup / valibot の比較
- 22. 実務での Tips とアンチパターン
- 23. まとめ
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 を含むモダンスタック案件多数。

コメント