zod の使い方 — TypeScriptファーストなスキーマ定義&バリデーションライブラリ
一言でいうと
Zodは、TypeScriptの型推論と完全に統合されたスキーマ定義・バリデーションライブラリです。スキーマを一度定義するだけで、ランタイムバリデーションとTypeScriptの静的型の両方が手に入ります。
どんな時に使う?
- APIリクエスト/レスポンスのバリデーション — 外部から受け取るJSONデータが期待通りの構造かをランタイムで検証したいとき
- フォーム入力のバリデーション — React Hook FormやConformなどと組み合わせて、フォームの入力値を型安全に検証したいとき
- 環境変数・設定ファイルの型安全な読み込み —
process.envの値をパースし、型付きのオブジェクトとして扱いたいとき
インストール
# npm
npm install zod
# yarn
yarn add zod
# pnpm
pnpm add zod
注意: この記事はZod v4(4.x系)を対象としています。v3以前とはAPIに差異がある部分があります。
TypeScript設定
tsconfig.jsonでstrictモードを有効にすることが推奨されます。
{
"compilerOptions": {
"strict": true
}
}
基本的な使い方
最も典型的なパターンは「スキーマを定義 → パース → 型推論」の3ステップです。
import { z } from "zod";
// 1. スキーマを定義
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().int().min(0).optional(),
});
// 2. スキーマからTypeScript型を推論
type User = z.infer<typeof UserSchema>;
// => { id: number; name: string; email: string; age?: number | undefined }
// 3. ランタイムでバリデーション(パース)
const result = UserSchema.parse({
id: 1,
name: "田中太郎",
email: "tanaka@example.com",
age: 30,
});
// => 成功すればバリデーション済みのオブジェクトが返る
console.log(result);
// { id: 1, name: "田中太郎", email: "tanaka@example.com", age: 30 }
不正なデータを渡すと例外がスローされます。
try {
UserSchema.parse({ id: "not-a-number", name: "", email: "invalid" });
} catch (err) {
if (err instanceof z.ZodError) {
console.error(err.issues);
// バリデーションエラーの詳細が配列で取得できる
}
}
よく使うAPI
1. z.object() — オブジェクトスキーマの定義
最も頻繁に使うAPIです。ネストも自由にできます。
import { z } from "zod";
const AddressSchema = z.object({
postalCode: z.string().regex(/^\d{3}-\d{4}$/),
prefecture: z.string(),
city: z.string(),
});
const PersonSchema = z.object({
name: z.string().min(1, "名前は必須です"),
address: AddressSchema,
});
type Person = z.infer<typeof PersonSchema>;
2. safeParse() — 例外を投げないバリデーション
parse()は失敗時に例外をスローしますが、safeParse()は結果オブジェクトを返します。実務ではこちらを使うケースが多いです。
import { z } from "zod";
const EmailSchema = z.string().email();
const success = EmailSchema.safeParse("user@example.com");
if (success.success) {
console.log(success.data); // "user@example.com"
}
const failure = EmailSchema.safeParse("not-an-email");
if (!failure.success) {
console.error(failure.error.issues);
// [{ code: 'invalid_string', validation: 'email', message: 'Invalid email', path: [] }]
}
3. z.enum() / z.union() — 列挙型・ユニオン型
import { z } from "zod";
// 文字列リテラルの列挙
const RoleSchema = z.enum(["admin", "editor", "viewer"]);
type Role = z.infer<typeof RoleSchema>; // "admin" | "editor" | "viewer"
RoleSchema.parse("admin"); // OK
// RoleSchema.parse("superuser"); // => ZodError
// 判別ユニオン(discriminated union)
const EventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("keypress"), key: z.string() }),
]);
type Event = z.infer<typeof EventSchema>;
4. z.array() / z.record() — 配列・レコード型
import { z } from "zod";
// 配列
const TagsSchema = z.array(z.string()).min(1).max(10);
TagsSchema.parse(["typescript", "zod"]); // OK
// レコード(キーが文字列、値が数値の辞書)
const ScoresSchema = z.record(z.string(), z.number());
type Scores = z.infer<typeof ScoresSchema>; // Record<string, number>
ScoresSchema.parse({ math: 90, english: 85 }); // OK
5. .transform() / .refine() — データ変換とカスタムバリデーション
import { z } from "zod";
// transform: パース後にデータを変換
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10));
const num = StringToNumberSchema.parse("42"); // 42 (number型)
// refine: カスタムバリデーションルールを追加
const PasswordSchema = z
.string()
.min(8, "8文字以上で入力してください")
.refine((val) => /[A-Z]/.test(val), {
message: "大文字を1文字以上含めてください",
})
.refine((val) => /[0-9]/.test(val), {
message: "数字を1文字以上含めてください",
});
// superRefine: 複数フィールドにまたがるバリデーション
const SignupSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "パスワードが一致しません",
path: ["confirmPassword"],
});
}
});
類似パッケージとの比較
| 特徴 | Zod | Yup | Joi | Valibot |
|---|---|---|---|---|
| TypeScript型推論 | ◎ 完全対応 | △ 部分的 | × 非対応 | ◎ 完全対応 |
| バンドルサイズ | 中(~13KB gzip) | 中(~12KB gzip) | 大(不向き) | 小(~2KB gzip) |
| ランタイム | ブラウザ/Node.js | ブラウザ/Node.js | 主にNode.js | ブラウザ/Node.js |
| API設計 | メソッドチェーン | メソッドチェーン | メソッドチェーン | 関数ベース |
| エコシステム | 非常に豊富 | 豊富 | 豊富 | 成長中 |
| 学習コスト | 低い | 低い | 中程度 | 低い |
選定の目安:
- TypeScriptプロジェクトでエコシステムの充実度を重視するなら Zod
- バンドルサイズを極限まで削りたいなら Valibot
- 既存のJavaScriptプロジェクトで使うなら Yup や Joi も選択肢
注意点・Tips
1. parse() vs safeParse() の使い分け
// ❌ try-catchで囲むのは冗長になりがち
try {
const data = schema.parse(input);
} catch (e) { /* ... */ }
// ✅ safeParse()で結果を判定する方がスッキリ書ける
const result = schema.safeParse(input);
if (!result.success) {
// エラーハンドリング
return result.error.issues;
}
// result.data は型安全
2. .strip() / .strict() / .passthrough() で未知のキーを制御する
const Schema = z.object({ name: z.string() });
// デフォルト(strip): 未知のキーは除去される
Schema.parse({ name: "太郎", unknown: true });
// => { name: "太郎" }
// strict: 未知のキーがあるとエラー
const StrictSchema = Schema.strict();
// StrictSchema.parse({ name: "太郎", unknown: true }); // => ZodError
// passthrough: 未知のキーもそのまま通す
const PassSchema = Schema.passthrough();
PassSchema.parse({ name: "太郎", unknown: true });
// => { name: "太郎", unknown: true }
3. エラーメッセージの日本語化
const NameSchema = z.string({
required_error: "名前は必須です",
invalid_type_error: "名前は文字列で入力してください",
}).min(1, "名前を入力してください");
4. 再帰的な型(ツリー構造など)の定義
import { z } from "zod";
interface Category {
name: string;
children: Category[];
}
const CategorySchema: z.ZodType<Category> = z.object({
name: z.string(),
children: z.lazy(() => z.array(CategorySchema)),
});
5. パフォーマンスに関する注意
- 大量のデータ(数万件の配列など)をバリデーションする場合、パフォーマンスに影響が出ることがあります。ホットパスでは必要最小限のスキーマに絞ることを検討してください。
z.inferはコンパイル時のみの処理なので、ランタイムコストはゼロです。
まとめ
Zodは「スキーマを書けば型が付いてくる」というDX(開発者体験)の良さが最大の魅力です。TypeScriptプロジェクトにおけるバリデーションのデファクトスタンダードと言える存在であり、React Hook Form・tRPC・Next.jsなど主要なエコシステムとの統合も充実しています。APIの境界やフォーム入力など「外部からのデータが入ってくる場所」には、積極的に導入を検討してみてください。