TypeScript × Zod バリデーション入門|API・フォームを型安全に守る実践パターン
Zodとは?なぜTypeScriptと相性が良いのか
Zodは、TypeScript向けに設計されたスキーマ宣言・バリデーションライブラリです。
従来のio-tsやyupと比べて、Zodが特に優れている点は「スキーマ定義からTypeScriptの型を自動導出できる」ことです。型定義とバリデーションロジックを二重管理する必要がなく、スキーマが唯一の真実(Single Source of Truth)になります。
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
});
// ↓ スキーマから型を自動導出。手書き不要!
type User = z.infer<typeof UserSchema>;
この1点だけでも、TypeScriptプロジェクトに導入する価値があります。
インストールと基本設定
npm install zod
# or
pnpm add zod
TypeScript 4.5以上、strict: true 設定を推奨します。Zodはそれ自体に型推論が多用されているため、strictモードで真価を発揮します。
基本的なスキーマの書き方
プリミティブ型
const schema = z.object({
name: z.string(),
age: z.number().int().min(0).max(150),
isActive: z.boolean(),
createdAt: z.date(),
role: z.enum(["admin", "user", "guest"]),
});
オプショナル・デフォルト値
const schema = z.object({
nickname: z.string().optional(), // undefined OK
bio: z.string().nullable(), // null OK
lang: z.string().default("ja"), // 未指定時に "ja"
});
バリデーション後の型
type Payload = z.infer<typeof schema>;
// { nickname?: string; bio: string | null; lang: string }
実践①:APIリクエストのバリデーション
Express / Hono / Cloudflare Workers などのバックエンドで、受け取ったJSONを安全に扱うパターンです。
import { z } from "zod";
// スキーマ定義
const CreatePostSchema = z.object({
title: z.string().min(1, "タイトルは必須です").max(100),
body: z.string().min(10, "本文は10文字以上で入力してください"),
tags: z.array(z.string()).max(5, "タグは最大5件まで"),
publishedAt: z.string().datetime().optional(),
});
type CreatePostInput = z.infer<typeof CreatePostSchema>;
// Honoでの使用例
app.post("/posts", async (c) => {
const raw = await c.req.json();
const result = CreatePostSchema.safeParse(raw);
if (!result.success) {
// result.error.flatten() でフィールド別エラーを取得
return c.json(
{ errors: result.error.flatten().fieldErrors },
400
);
}
// result.data は CreatePostInput 型として安全に使える
const post = await createPost(result.data);
return c.json(post, 201);
});
ポイント:parse vs safeParse
| メソッド | 失敗時の挙動 | 使いどころ |
|---|---|---|
parse() | 例外をthrow | 信頼できる内部データ |
safeParse() | { success, data/error } を返す | 外部入力・APIリクエスト |
外部入力には必ず safeParse を使い、例外の漏れを防ぎましょう。
実践②:フォームバリデーション(React Hook Form連携)
フロントエンドでは React Hook Form + Zod の組み合わせが定番です。@hookform/resolvers を使います。
npm install react-hook-form @hookform/resolvers
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文字以上"),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "パスワードが一致しません",
path: ["confirm"], // エラーを表示するフィールドを指定
});
type SignupInput = z.infer<typeof SignupSchema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupInput>({
resolver: zodResolver(SignupSchema),
});
const onSubmit = (data: SignupInput) => {
console.log(data); // 型安全なデータ
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...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("confirm")} />
{errors.confirm && <p>{errors.confirm.message}</p>}
<button type="submit">登録</button>
</form>
);
}
.refine() を使うことで、フィールドをまたいだ相関バリデーション(パスワード確認など)もスキーマ側に集約できます。
実践③:ネスト・Union・変換(transform)
ネストしたオブジェクト
const AddressSchema = z.object({
zip: z.string().regex(/^\d{7}$/, "7桁の数字で入力"),
prefecture: z.string(),
city: z.string(),
});
const OrderSchema = z.object({
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})
),
shippingAddress: AddressSchema, // スキーマを再利用
});
Union型(複数パターンのどれか)
const EventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("keydown"), key: z.string() }),
]);
discriminatedUnion は判別キー(type)で分岐するため、通常の z.union よりパフォーマンスが良くエラーメッセージも明確です。
transform で入力値を変換
const TrimmedStringSchema = z
.string()
.trim() // 前後空白を除去
.transform((val) => val.toLowerCase()); // 小文字化
// parse後の型は string
const result = TrimmedStringSchema.parse(" Hello World ");
// → "hello world"
エラーハンドリングの実践的な整形
function formatZodErrors(error: z.ZodError) {
return error.errors.map((e) => ({
field: e.path.join("."),
message: e.message,
}));
}
const result = CreatePostSchema.safeParse(input);
if (!result.success) {
const errors = formatZodErrors(result.error);
// [{ field: "title", message: "タイトルは必須です" }, ...]
}
error.flatten() も便利ですが、ネストしたフィールドのパスを正確に取りたい場合は error.errors を直接使う方が柔軟です。
よくある落とし穴と対策
| 問題 | 原因 | 対策 |
|---|---|---|
parse が例外を出し続ける | 外部入力に parse を使っている | safeParse に変更する |
z.date() がAPIで機能しない | JSON日付は文字列で届く | z.string().datetime() → transform(new Date(...)) |
optional() と nullable() の混同 | 意味が違う | optional = undefinedOK、nullable = nullOK |
| スキーマが巨大になる | 1ファイルに全スキーマ | ドメインごとにファイルを分割して import |
まとめ
Zodを使ったバリデーションの実践パターンをまとめます。
- スキーマから型を導出(
z.infer)することで型定義の二重管理をなくす - 外部入力には
safeParseを使い、例外を制御する - フォームは React Hook Form + zodResolver の組み合わせが最も管理しやすい
.refine()で相関バリデーション、.transform()でデータ変換をスキーマに集約するdiscriminatedUnionでUnion型のパフォーマンスと可読性を向上させる
Zodを導入するだけで「型は合っているのにランタイムでクラッシュする」という問題の大半を防げます。まずは既存プロジェクトのAPIエンドポイント1本から試してみてください。