TypeScript で Zod を使いこなす ─ ランタイム型バリデーション完全ガイド
TypeScript を使っていても、「外から来たデータ」の型は保証できません。
APIのレスポンス、フォームの入力値、JSONファイルの中身... これらは実行時に初めてわかるデータで、TypeScript の型システムはコンパイル時にしか機能しません。
const response = await fetch("/api/user");
const user = await response.json() as User; // ← この as は嘘をついているかもしれない
Zod はこの問題を解決するライブラリです。スキーマを定義すると、TypeScript の型定義とランタイムのバリデーションを同時に得られます。
この記事で学べること:
- Zod の基本的なスキーマ定義
- TypeScript 型との連携(
z.infer) - よく使うスキーマパターン(オブジェクト・配列・ユニオン・変換)
- APIレスポンスのバリデーション実践例
- フォームバリデーションへの応用
検証環境: TypeScript 5+, Zod 3.x
インストールと基本
npm install zod
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>;
// → { id: number; name: string; email: string }
// バリデーション
const result = UserSchema.safeParse({
id: 1,
name: "Alice",
email: "alice@example.com",
});
if (result.success) {
console.log(result.data); // User 型として使える
} else {
console.log(result.error.issues); // エラー詳細
}
safeParse は例外を投げずに結果オブジェクトを返します。parse は失敗時に例外を投げます。基本的には safeParse を使う方が安全です。
プリミティブ型のスキーマ
import { z } from "zod";
// 基本型
z.string()
z.number()
z.boolean()
z.date()
z.undefined()
z.null()
z.any()
z.unknown()
// 制約付き
z.string().min(1) // 最小1文字
z.string().max(100) // 最大100文字
z.string().email() // メールアドレス形式
z.string().url() // URL形式
z.string().uuid() // UUID形式
z.string().regex(/^[A-Z]+$/) // 正規表現
z.number().min(0) // 0以上
z.number().max(100) // 100以下
z.number().int() // 整数のみ
z.number().positive() // 正の数
z.number().nonnegative() // 0以上
// Literal(特定の値のみ)
z.literal("active")
z.literal(42)
z.literal(true)
オブジェクト・配列・ユニオン
オブジェクト
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{3}-\d{4}$/),
country: z.string().default("JP"), // デフォルト値
});
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1).max(50),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(), // オプショナル
address: AddressSchema.optional(),
});
type User = z.infer<typeof UserSchema>;
配列
const TagsSchema = z.array(z.string()).min(1).max(10);
const NumberListSchema = z.array(z.number().positive());
// タプル(固定長配列)
const CoordinateSchema = z.tuple([z.number(), z.number()]);
type Coordinate = z.infer<typeof CoordinateSchema>; // [number, number]
ユニオン
// 文字列ユニオン
const StatusSchema = z.union([
z.literal("pending"),
z.literal("running"),
z.literal("done"),
z.literal("error"),
]);
// または enum で書ける
const StatusSchema2 = z.enum(["pending", "running", "done", "error"]);
type Status = z.infer<typeof StatusSchema>; // "pending" | "running" | "done" | "error"
// 型ユニオン(discriminated union)
const ResponseSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("success"),
data: z.array(z.unknown()),
}),
z.object({
type: z.literal("error"),
message: z.string(),
code: z.number(),
}),
]);
type Response = z.infer<typeof ResponseSchema>;
// { type: "success"; data: unknown[] } | { type: "error"; message: string; code: number }
変換(transform)
バリデーション後にデータを変換できます。
// 文字列 → 数値に変換
const NumberFromStringSchema = z.string().transform(val => parseInt(val, 10));
// 日付文字列 → Date オブジェクトに変換
const DateFromStringSchema = z.string().datetime().transform(val => new Date(val));
// オブジェクトの変換
const ApiUserSchema = z.object({
user_id: z.number(), // スネークケース
full_name: z.string(),
email_address: z.string().email(),
}).transform(data => ({
userId: data.user_id, // キャメルケースに変換
fullName: data.full_name,
email: data.email_address,
}));
type User = z.infer<typeof ApiUserSchema>;
// { userId: number; fullName: string; email: string }
const result = ApiUserSchema.parse({
user_id: 1,
full_name: "Alice Smith",
email_address: "alice@example.com",
});
console.log(result.userId); // 1(キャメルケースに変換済み)
実践: APIレスポンスのバリデーション
import { z } from "zod";
// スキーマ定義
const PostSchema = z.object({
id: z.number(),
title: z.string().min(1),
body: z.string(),
userId: z.number(),
tags: z.array(z.string()).default([]),
createdAt: z.string().datetime().optional(),
});
const PostsResponseSchema = z.object({
posts: z.array(PostSchema),
total: z.number(),
page: z.number(),
limit: z.number(),
});
type Post = z.infer<typeof PostSchema>;
type PostsResponse = z.infer<typeof PostsResponseSchema>;
// フェッチ関数
async function fetchPosts(page: number = 1): Promise<PostsResponse> {
const response = await fetch(`/api/posts?page=${page}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const raw = await response.json();
// バリデーション
const result = PostsResponseSchema.safeParse(raw);
if (!result.success) {
console.error("APIレスポンスの形式が不正:", result.error.issues);
throw new Error("APIレスポンスの形式が不正です");
}
return result.data; // 型安全なデータ
}
// 使い方
const { posts, total } = await fetchPosts(1);
posts.forEach(post => {
console.log(post.title); // string として確定している
});
実践: フォームバリデーション
React Hook Form と組み合わせる例(@hookform/resolvers が必要)。
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const ContactFormSchema = z.object({
name: z.string()
.min(1, "名前を入力してください")
.max(50, "名前は50文字以内で入力してください"),
email: z.string()
.min(1, "メールアドレスを入力してください")
.email("有効なメールアドレスを入力してください"),
subject: z.enum(["inquiry", "support", "feedback"], {
errorMap: () => ({ message: "件名を選択してください" }),
}),
message: z.string()
.min(10, "メッセージは10文字以上入力してください")
.max(1000, "メッセージは1000文字以内で入力してください"),
agreeToTerms: z.boolean()
.refine(val => val === true, "利用規約に同意してください"),
});
type ContactFormData = z.infer<typeof ContactFormSchema>;
export default function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(ContactFormSchema),
});
const onSubmit = async (data: ContactFormData) => {
// data は ContactFormData 型として保証されている
await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register("name")} placeholder="名前" />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input {...register("email")} placeholder="メールアドレス" />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<textarea {...register("message")} placeholder="メッセージ" />
{errors.message && <span>{errors.message.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>送信</button>
</form>
);
}
refine と superRefine ─ カスタムバリデーション
// パスワード確認フィールドの例
const PasswordFormSchema = z.object({
password: z.string().min(8, "パスワードは8文字以上"),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "パスワードが一致しません",
path: ["confirmPassword"], // エラーをつけるフィールド
});
// 複数のカスタムバリデーション
const ScheduleSchema = z.object({
startDate: z.date(),
endDate: z.date(),
}).superRefine((data, ctx) => {
if (data.endDate <= data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "終了日は開始日より後に設定してください",
path: ["endDate"],
});
}
});
まとめ
Zod を使う理由をひとことで言うと、 「TypeScript の型とランタイムのバリデーションを1つのスキーマで管理できる」 からです。
// スキーマ1つで型定義とバリデーションが揃う
const UserSchema = z.object({ ... });
type User = z.infer<typeof UserSchema>; // 型定義
const validated = UserSchema.safeParse(data); // バリデーション
外部からデータが入ってくる場所(APIレスポンス・フォーム・設定ファイル)では、Zod でスキーマを定義しておくと、型安全性と実行時の安全性を両立できます。as でのキャストに頼る機会がぐっと減ります。