TypeScriptの型システムはコンパイル時に型の整合性を検証してくれますが、実行時のデータ(APIレスポンス、フォーム入力、環境変数など)までは保証してくれません。Zodは、スキーマ定義から型推論と実行時バリデーションの両方を提供する、TypeScriptファーストのライブラリです。
本記事では、Zodの基本から実務で使える応用パターンまで、コード例とともに詳しく解説します。
Zodとは何か・なぜ必要なのか
Zodは、TypeScript向けに設計されたスキーマ宣言・バリデーションライブラリです。スキーマを一度定義するだけで、TypeScriptの型と実行時のバリデーションロジックの両方が手に入ります。
TypeScriptの型だけでは不十分な理由
TypeScriptの型はコンパイル時にのみ存在し、実行時にはJavaScriptとして動作します。つまり、外部から入ってくるデータの安全性は保証されません。
// TypeScriptの型定義
interface User {
name: string;
email: string;
age: number;
}
// APIレスポンスを「信頼して」型アサーション
const data = await fetch("/api/user").then((r) => r.json()) as User;
// もしAPIが { name: 123, age: "hello" } を返したら?
// TypeScriptはエラーを出さないが、実行時にバグが発生する
console.log(data.name.toUpperCase()); // ランタイムエラーの可能性
Zodを使えば、実行時にデータの構造と型を検証し、安全な値だけを通すことができます。
Zodの特徴と他ライブラリとの比較
Zodの主な特徴は以下のとおりです。
TypeScriptファースト:スキーマから自動的にTypeScript型を推論できるため、型定義とバリデーションの二重管理が不要です。
ゼロ依存:外部ライブラリへの依存がなく、バンドルサイズが小さいです。
イミュータブル:メソッドチェーンで新しいスキーマを返すため、元のスキーマに影響しません。
充実したエラー情報:どのフィールドがどのような理由で不正かを詳細に報告してくれます。
類似ライブラリとしてYup、io-ts、Valibotなどがありますが、Zodは型推論の精度とAPIの直感性で高い評価を得ています。
Zodのセットアップと基本的な使い方
インストール
# npm
npm install zod
# pnpm
pnpm add zod
# yarn
yarn add zod
TypeScript 4.5以上が推奨されます。tsconfig.jsonでstrict: trueを有効にしてください。
基本的なスキーマ定義
import { z } from "zod";
// プリミティブ型
const nameSchema = z.string();
const ageSchema = z.number();
const isActiveSchema = z.boolean();
// バリデーション実行
const name = nameSchema.parse("田中太郎"); // "田中太郎"
// nameSchema.parse(123); // ZodError: Expected string, received number
// 安全なパース(例外を投げない)
const result = nameSchema.safeParse("田中太郎");
if (result.success) {
console.log(result.data); // "田中太郎"
} else {
console.error(result.error.issues);
}
オブジェクトスキーマと型推論
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "editor", "viewer"]),
});
// スキーマからTypeScript型を推論
type User = z.infer<typeof UserSchema>;
// {
// name: string;
// email: string;
// age: number;
// role: "admin" | "editor" | "viewer";
// }
// バリデーション
const user = UserSchema.parse({
name: "田中太郎",
email: "tanaka@example.com",
age: 30,
role: "admin",
});
// user は User 型として推論される
このようにz.inferを使えば、スキーマ定義から自動的にTypeScript型が得られるため、型とバリデーションを二重に定義する必要がありません。
バリデーションルールの詳細
Zodは豊富なバリデーションメソッドを提供しています。
文字列のバリデーション
const stringSchemas = {
// 長さ制限
username: z.string().min(3, "3文字以上で入力してください").max(20, "20文字以内で入力してください"),
// 正規表現
phone: z.string().regex(/^0\d{1,4}-\d{1,4}-\d{4}$/, "電話番号の形式が不正です"),
// 組み込みバリデーション
email: z.string().email("メールアドレスの形式が不正です"),
url: z.string().url("URLの形式が不正です"),
uuid: z.string().uuid("UUIDの形式が不正です"),
// トリム・変換
trimmed: z.string().trim(),
lowered: z.string().toLowerCase(),
// 空文字を禁止
nonEmpty: z.string().min(1, "必須項目です"),
// 日付文字列
dateString: z.string().date("日付の形式が不正です"), // YYYY-MM-DD
dateTimeString: z.string().datetime("日時の形式が不正です"),
};
数値のバリデーション
const numberSchemas = {
// 範囲
age: z.number().int().min(0).max(150),
// 正の数・負の数
price: z.number().positive("価格は正の数で入力してください"),
temperature: z.number().min(-50).max(60),
// 有限の数
finiteNumber: z.number().finite(),
// 整数
quantity: z.number().int("整数で入力してください").nonnegative(),
// 文字列から数値への変換
numericString: z.coerce.number(), // "123" → 123
};
配列・タプル・列挙型
// 配列
const tagsSchema = z.array(z.string()).min(1, "1つ以上のタグが必要です").max(10);
// タプル
const coordinateSchema = z.tuple([z.number(), z.number()]);
// 列挙型
const statusSchema = z.enum(["active", "inactive", "pending"]);
type Status = z.infer<typeof statusSchema>; // "active" | "inactive" | "pending"
// ネイティブEnum
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
const directionSchema = z.nativeEnum(Direction);
オプショナル・デフォルト値・nullable
const ProfileSchema = z.object({
name: z.string(),
bio: z.string().optional(), // string | undefined
website: z.string().url().nullable(), // string | null
theme: z.string().default("light"), // デフォルト値付き
notifications: z.boolean().default(true),
});
type Profile = z.infer<typeof ProfileSchema>;
// {
// name: string;
// bio?: string | undefined;
// website: string | null;
// theme: string;
// notifications: boolean;
// }
実務で使えるスキーマパターン
APIリクエスト・レスポンスのバリデーション
// APIレスポンススキーマ
const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
success: z.boolean(),
data: dataSchema,
message: z.string().optional(),
});
const UserListResponseSchema = ApiResponseSchema(
z.array(UserSchema)
);
// APIリクエストのバリデーション
const CreateUserRequestSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).optional(),
role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
});
type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
// Expressミドルウェアでの使用例
import { Request, Response, NextFunction } from "express";
function validateBody<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
errors: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
req.body = result.data;
next();
};
}
// ルート定義
app.post("/api/users", validateBody(CreateUserRequestSchema), (req, res) => {
const userData: CreateUserRequest = req.body; // 型安全
// ...
});
環境変数のバリデーション
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(32, "APIシークレットは32文字以上必要です"),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
type Env = z.infer<typeof EnvSchema>;
function loadEnv(): Env {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error("環境変数の検証に失敗しました:");
for (const issue of result.error.issues) {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
}
process.exit(1);
}
return result.data;
}
export const env = loadEnv();
// env.PORT は number 型として使える
// env.DATABASE_URL は string 型として使える
フォームバリデーション(React Hook Form連携)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const ContactFormSchema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.string().email("メールアドレスの形式が不正です"),
category: z.enum(["inquiry", "support", "other"], {
errorMap: () => ({ message: "カテゴリを選択してください" }),
}),
message: z
.string()
.min(10, "10文字以上で入力してください")
.max(1000, "1000文字以内で入力してください"),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: "利用規約に同意してください" }),
}),
});
type ContactForm = z.infer<typeof ContactFormSchema>;
function ContactFormComponent() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactForm>({
resolver: zodResolver(ContactFormSchema),
});
const onSubmit = (data: ContactForm) => {
// data は型安全かつバリデーション済み
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register("message")} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit">送信</button>
</form>
);
}
高度なスキーマ操作
スキーマの合成と拡張
// ベーススキーマ
const BaseEntitySchema = z.object({
id: z.number().int().positive(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
// 拡張(mergeを使用)
const ArticleSchema = BaseEntitySchema.merge(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
author: z.string(),
tags: z.array(z.string()),
status: z.enum(["draft", "published", "archived"]),
})
);
type Article = z.infer<typeof ArticleSchema>;
// pick / omit でスキーマを加工
const ArticleCreateSchema = ArticleSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
const ArticleSummarySchema = ArticleSchema.pick({
id: true,
title: true,
author: true,
status: true,
});
// partial で全フィールドをオプショナルに
const ArticleUpdateSchema = ArticleSchema.omit({
id: true,
createdAt: true,
}).partial();
discriminatedUnion(判別共用体)
const NotificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
z.object({
type: z.literal("slack"),
channel: z.string(),
message: z.string(),
mention: z.array(z.string()).optional(),
}),
z.object({
type: z.literal("webhook"),
url: z.string().url(),
payload: z.record(z.unknown()),
}),
]);
type Notification = z.infer<typeof NotificationSchema>;
// 型安全に分岐処理できる
function sendNotification(notification: Notification) {
switch (notification.type) {
case "email":
// notification.to, notification.subject が使える
break;
case "slack":
// notification.channel, notification.message が使える
break;
case "webhook":
// notification.url, notification.payload が使える
break;
}
}
transform・refine・preprocessによるカスタムバリデーション
// transform: データの変換
const DateStringSchema = z.string().transform((val) => new Date(val));
// refine: カスタムバリデーション
const PasswordSchema = z
.string()
.min(8, "8文字以上で入力してください")
.refine((val) => /[A-Z]/.test(val), "大文字を1文字以上含めてください")
.refine((val) => /[a-z]/.test(val), "小文字を1文字以上含めてください")
.refine((val) => /[0-9]/.test(val), "数字を1文字以上含めてください");
// superRefine: 複数フィールド間のバリデーション
const RegisterFormSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "パスワードが一致しません",
path: ["confirmPassword"],
});
}
if (data.endDate <= data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "終了日は開始日より後にしてください",
path: ["endDate"],
});
}
});
// preprocess: パース前にデータを前処理
const NumericStringSchema = z.preprocess(
(val) => (typeof val === "string" ? Number(val) : val),
z.number().positive()
);
エラーハンドリングとカスタムエラーメッセージ
エラー情報の構造
const result = UserSchema.safeParse({
name: "",
email: "invalid-email",
age: -5,
role: "unknown",
});
if (!result.success) {
// flatten でフィールドごとのエラーに整理
const fieldErrors = result.error.flatten().fieldErrors;
// {
// name: ["String must contain at least 1 character(s)"],
// email: ["Invalid email"],
// age: ["Number must be greater than or equal to 0"],
// role: ["Invalid enum value..."]
// }
// format でネスト構造を維持
const formatted = result.error.format();
// formatted.email?._errors → ["Invalid email"]
}
日本語エラーメッセージの設定
// グローバルなエラーマップ
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.invalid_type:
if (issue.expected === "string") return { message: "文字列を入力してください" };
if (issue.expected === "number") return { message: "数値を入力してください" };
break;
case z.ZodIssueCode.too_small:
if (issue.type === "string") return { message: `${issue.minimum}文字以上で入力してください` };
if (issue.type === "number") return { message: `${issue.minimum}以上の値を入力してください` };
break;
case z.ZodIssueCode.too_big:
if (issue.type === "string") return { message: `${issue.maximum}文字以内で入力してください` };
break;
case z.ZodIssueCode.invalid_string:
if (issue.validation === "email") return { message: "メールアドレスの形式が不正です" };
if (issue.validation === "url") return { message: "URLの形式が不正です" };
break;
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
// フィールドごとの個別メッセージ
const ItemSchema = z.object({
name: z.string({ required_error: "商品名は必須です" }).min(1, "商品名を入力してください"),
price: z.number({ required_error: "価格は必須です" }).positive("価格は正の数で入力してください"),
});
tRPC・Next.jsとの連携
tRPCでのZod活用
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => {
// input.id は number 型として型安全
return await db.user.findUnique({ where: { id: input.id } });
}),
createUser: t.procedure
.input(CreateUserRequestSchema) // 再利用
.mutation(async ({ input }) => {
// input は CreateUserRequest 型
return await db.user.create({ data: input });
}),
searchUsers: t.procedure
.input(
z.object({
query: z.string().optional(),
role: z.enum(["admin", "editor", "viewer"]).optional(),
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().max(100).default(20),
})
)
.query(async ({ input }) => {
// ページネーション付き検索
return await db.user.findMany({
where: {
name: input.query ? { contains: input.query } : undefined,
role: input.role,
},
skip: (input.page - 1) * input.perPage,
take: input.perPage,
});
}),
});
Next.js Server Actionsでの活用
"use server";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.string().email("メールアドレスの形式が不正です"),
message: z.string().min(10, "10文字以上で入力してください"),
});
export async function submitContact(formData: FormData) {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const result = ContactSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// result.data は型安全
await sendEmail(result.data);
return { success: true };
}
まとめ
Zodは、TypeScriptプロジェクトにおけるバリデーションの課題を根本から解決するライブラリです。
基本的な使い方:z.object、z.string、z.numberなどでスキーマを定義し、parseまたはsafeParseでバリデーションを実行します。z.inferでTypeScript型を自動推論でき、型の二重定義が不要です。
実務での活用:APIリクエスト・レスポンスの検証、環境変数のバリデーション、React Hook Formとの連携、tRPCやNext.js Server Actionsでの型安全なデータ受け取りなど、幅広い場面で活用できます。
高度な機能:discriminatedUnionによる判別共用体、transform・refineによるカスタム変換・検証、スキーマのmerge・pick・omitによる合成も可能です。
「型で守れる範囲は型で、実行時に守る範囲はZodで」という方針を徹底することで、予期しないデータによるバグを大幅に削減できます。まずは環境変数やAPIリクエストのバリデーションから導入してみてください。
関連記事
AIエージェント開発入門|自律型AIの仕組みと構築方法を解説【2026年版】
AI駆動コーディングワークフロー|Claude Code・Cursor・Copilotの実践的使い分け
プロンプトエンジニアリング上級編|Chain-of-Thought・Few-Shot・ReActの実践
APIレート制限の設計と実装|トークンバケット・スライディングウィンドウ解説
APIバージョニング戦略|URL・ヘッダー・クエリパラメータの使い分け
BIツール入門|Metabase・Redash・Looker Studioでデータ可視化する方法
チャットボット開発入門|LINE Bot・Slack Botの構築方法と活用事例
CI/CDパイプラインの基礎|継続的インテグレーション・デリバリーの全体像