Zod でランタイムバリデーションと型安全性を両立する実践ガイド
API レスポンス・フォーム入力・環境変数を安全に扱う Zod の実装方法を解説。TypeScript の型推論、エラーハンドリング、パフォーマンス最適化まで受託開発で即使える実務ガイド。

Zod でランタイムバリデーションと型安全性を両立する実践ガイド
TypeScript で型を定義しても、外部から受け取るデータ(API レスポンス・フォーム入力・環境変数)は実行時まで正しいかわかりません。Zod を使うと、スキーマ定義から型推論とランタイムバリデーションを同時に実現でき、実行時エラーを大幅に削減できます。
本記事では、受託開発・自社開発の現場で即使える Zod の実装パターンを、コード例・エラーハンドリング・パフォーマンス最適化まで網羅的に解説します。
1. Zod を導入すべき場面と導入しない場面
Zod が最も効果を発揮する場面
| 場面 | 効果 | 優先度 | |------|------|--------| | 外部 API のレスポンス検証 | 想定外のスキーマ変更を検出 | ★★★ | | フォーム入力のバリデーション | サーバー・クライアント両方で同じスキーマを使用可能 | ★★★ | | 環境変数の検証 | 起動時に必須変数の欠落を検出 | ★★★ | | GraphQL / REST API の入力検証 | リクエストボディの型安全性を保証 | ★★★ | | ファイルアップロードの検証 | MIME タイプ・サイズ制限を型安全に実装 | ★★☆ |
Zod を使わない方がよい場面
- 内部モジュール間の型定義:TypeScript の型定義のみで十分
- 単純な Boolean チェック:
if (!value) throw new Error()で十分 - 高頻度・大量データの検証:パフォーマンスオーバーヘッドが無視できない場合(後述の最適化テクニックで対処可能)
2. 基本的なスキーマ定義と型推論
2-1. プリミティブ型と型推論
import { z } from 'zod';
// スキーマ定義
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
createdAt: z.string().datetime(), // ISO 8601 形式
isActive: z.boolean().default(true),
});
// 型を自動推論
type User = z.infer<typeof UserSchema>;
// → { id: number; name: string; email: string; role: 'admin' | 'editor' | 'viewer'; createdAt: string; isActive: boolean }
// バリデーション実行
const result = UserSchema.safeParse({
id: 1,
name: 'Alice',
email: 'alice@example.com',
role: 'admin',
createdAt: '2026-06-18T10:00:00Z',
});
if (result.success) {
console.log(result.data); // 型安全なデータ
} else {
console.error(result.error.issues); // バリデーションエラー詳細
}
2-2. オプショナル・Nullable・デフォルト値
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string().optional(), // undefined 許容
price: z.number().nullable(), // null 許容
stock: z.number().default(0), // デフォルト値
tags: z.array(z.string()).default([]), // 配列のデフォルト
});
type Product = z.infer<typeof ProductSchema>;
// → { id: number; name: string; description?: string; price: number | null; stock: number; tags: string[] }
3. API レスポンスの検証パターン
3-1. fetch 関数のラッパー実装
import { z } from 'zod';
const fetchWithSchema = async <T extends z.ZodTypeAny>(
url: string,
schema: T,
options?: RequestInit
): Promise<z.infer<T>> => {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const json = await response.json();
const result = schema.safeParse(json);
if (!result.success) {
console.error('Validation failed:', result.error.issues);
throw new Error('Invalid API response schema');
}
return result.data;
};
// 使用例
const UserListSchema = z.array(UserSchema);
const users = await fetchWithSchema(
'https://api.example.com/users',
UserListSchema
);
// users は User[] 型として推論される
3-2. Next.js Server Actions での検証
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
authorId: z.number(),
tags: z.array(z.string()).max(5),
});
export async function createPost(
input: z.infer<typeof CreatePostSchema>
) {
// バリデーション
const validated = CreatePostSchema.parse(input);
// DB 挿入(validated は型安全)
const post = await db.post.create({
data: validated,
});
return { success: true, postId: post.id };
}
4. フォームバリデーション(React Hook Form 統合)
4-1. zodResolver による統合
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(8, 'パスワードは8文字以上必要です'),
rememberMe: z.boolean().default(false),
});
type LoginFormData = z.infer<typeof LoginSchema>;
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(LoginSchema),
});
const onSubmit = async (data: LoginFormData) => {
// data は自動的に検証済み
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<label>
<input type="checkbox" {...register('rememberMe')} />
ログイン状態を保持
</label>
<button type="submit">ログイン</button>
</form>
);
}
4-2. カスタムバリデーションルール
const PasswordResetSchema = z.object({
password: z.string()
.min(8, 'パスワードは8文字以上')
.regex(/[A-Z]/, '大文字を1文字以上含めてください')
.regex(/[0-9]/, '数字を1文字以上含めてください'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'], // エラー表示位置を指定
});
5. 環境変数の検証(起動時チェック)
5-1. 必須環境変数の検証
// lib/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NEXT_PUBLIC_API_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.string().transform(Number).pipe(z.number().min(1000)),
});
export const env = EnvSchema.parse(process.env);
// 起動時に検証失敗すると即座にエラーで停止
5-2. Next.js での環境変数検証
// next.config.js
const { z } = require('zod');
const serverEnv = z.object({
DATABASE_URL: z.string().url(),
});
const clientEnv = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
});
const processEnv = {
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
};
if (!serverEnv.safeParse(processEnv).success ||
!clientEnv.safeParse(processEnv).success) {
throw new Error('Invalid environment variables');
}
module.exports = {
// ... Next.js config
};
6. エラーハンドリングのベストプラクティス
6-1. エラーメッセージのカスタマイズ
const CustomErrorSchema = z.object({
age: z.number({
required_error: '年齢は必須です',
invalid_type_error: '年齢は数値で入力してください',
}).min(18, { message: '18歳以上である必要があります' }),
});
6-2. エラー情報の整形
import { ZodError } from 'zod';
function formatZodError(error: ZodError): Record<string, string> {
const formatted: Record<string, string> = {};
error.issues.forEach((issue) => {
const path = issue.path.join('.');
formatted[path] = issue.message;
});
return formatted;
}
const result = UserSchema.safeParse(invalidData);
if (!result.success) {
const errors = formatZodError(result.error);
// { "email": "Invalid email", "age": "Number must be greater than or equal to 18" }
}
6-3. API エラーレスポンスの統一
// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const validated = CreateUserSchema.parse(body);
// ユーザー作成処理
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: formatZodError(error) },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
7. パフォーマンス最適化テクニック
7-1. スキーマの再利用とメモ化
// ❌ 避けるべき:毎回スキーマを生成
function validateUser(data: unknown) {
const schema = z.object({ name: z.string() });
return schema.parse(data);
}
// ✅ 推奨:スキーマを事前定義
const UserSchema = z.object({ name: z.string() });
function validateUser(data: unknown) {
return UserSchema.parse(data);
}
7-2. 部分的バリデーション(partial / pick)
const FullUserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number(),
address: z.string(),
});
// 一部のフィールドのみ検証
const UpdateUserSchema = FullUserSchema.pick({
name: true,
email: true,
});
// すべてのフィールドをオプショナルに
const PartialUserSchema = FullUserSchema.partial();
7-3. 非同期バリデーションの最適化
const EmailSchema = z.string().email().refine(
async (email) => {
// DB でメールアドレスの重複チェック
const exists = await db.user.findUnique({ where: { email } });
return !exists;
},
{ message: 'このメールアドレスは既に使用されています' }
);
// 使用例(parseAsync を使用)
const result = await EmailSchema.safeParseAsync('test@example.com');
8. 実務での応用パターン
8-1. マルチステップフォームの検証
// ステップごとにスキーマを定義
const Step1Schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const Step2Schema = z.object({
company: z.string().min(1),
role: z.string().min(1),
});
const Step3Schema = z.object({
agreeToTerms: z.boolean().refine((val) => val === true, {
message: '利用規約への同意が必要です',
}),
});
// 最終的な統合スキーマ
const FullRegistrationSchema = Step1Schema
.merge(Step2Schema)
.merge(Step3Schema);
type RegistrationData = z.infer<typeof FullRegistrationSchema>;
8-2. ファイルアップロードの検証
const FileUploadSchema = z.object({
file: z
.instanceof(File)
.refine((file) => file.size <= 5 * 1024 * 1024, {
message: 'ファイルサイズは5MB以下にしてください',
})
.refine(
(file) => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type),
{ message: 'JPEG, PNG, WebP 形式のみ対応しています' }
),
});
// 使用例(フォーム送信時)
const handleFileUpload = (formData: FormData) => {
const file = formData.get('file');
const result = FileUploadSchema.safeParse({ file });
if (!result.success) {
console.error(result.error.issues);
return;
}
// アップロード処理
};
8-3. GraphQL リゾルバでの検証
import { z } from 'zod';
const CreatePostInputSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
authorId: z.string().uuid(),
});
const resolvers = {
Mutation: {
createPost: async (_parent: any, args: any, context: any) => {
// 入力検証
const input = CreatePostInputSchema.parse(args.input);
// DB 処理
const post = await context.db.post.create({
data: input,
});
return post;
},
},
};
9. よくあるトラブルと対処法
9-1. Date 型の扱い
// ❌ Date オブジェクトは JSON でシリアライズされると文字列になる
const BadSchema = z.object({
createdAt: z.date(), // API レスポンスでは失敗する
});
// ✅ 文字列として受け取り、変換する
const GoodSchema = z.object({
createdAt: z.string().datetime().transform((val) => new Date(val)),
});
// または coerce を使用
const CoerceSchema = z.object({
createdAt: z.coerce.date(), // 自動的に Date に変換
});
9-2. Union 型のパース優先順位
// ❌ 順序によって意図しない結果になる
const BadUnion = z.union([
z.string(), // すべての値が string と判定される可能性
z.string().email(),
]);
// ✅ より具体的なスキーマを先に配置
const GoodUnion = z.union([
z.string().email(),
z.string(),
]);
// または discriminated union を使用
const DiscriminatedUnion = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), value: z.string().email() }),
z.object({ type: z.literal('phone'), value: z.string().regex(/^\d{10,11}$/) }),
]);
9-3. Prisma との型不一致
import { Prisma } from '@prisma/client';
import { z } from 'zod';
// Prisma の型を Zod スキーマに変換
const UserCreateInputSchema: z.ZodType<Prisma.UserCreateInput> = z.object({
email: z.string().email(),
name: z.string().optional(),
posts: z.object({
create: z.array(z.object({
title: z.string(),
content: z.string(),
})),
}).optional(),
});
まとめ
Zod を導入することで、以下の実務課題を解決できます。
| 課題 | Zod による解決 | |------|----------------| | API レスポンスの型不一致 | スキーマ定義によるランタイム検証 | | フォーム入力の不正データ | サーバー・クライアント共通のバリデーション | | 環境変数の設定ミス | 起動時の自動チェックで早期発見 | | TypeScript 型定義の二重管理 | スキーマから型を自動推論 | | エラーメッセージの不統一 | カスタムエラーメッセージの一元管理 |
導入時のチェックリスト
- [ ] 外部 API レスポンスにスキーマ検証を追加
- [ ] フォームバリデーションを React Hook Form + Zod に移行
- [ ] 環境変数の検証スクリプトを起動プロセスに組み込み
- [ ] エラーハンドリングを統一(
safeParseの活用) - [ ] パフォーマンスが重要な箇所でスキーマを事前定義
- [ ] Prisma スキーマとの整合性を確認
次のステップ
- 公式ドキュメントで詳細な API リファレンスを確認
- zod-to-json-schema で OpenAPI 定義を自動生成
- zod-prisma で Prisma スキーマから Zod スキーマを生成
受託開発会社 Yureate では、型安全性を重視した Web アプリケーション開発を支援しています。Zod を含むモダンな技術スタックでの開発・技術相談は、お問い合わせページからお気軽にご連絡ください。
