ZodでTypeScriptの型安全バリデーション完全ガイド!フォーム検証からAPI連携まで実践例で学ぶ

Zodとは何か?TypeScript開発者が注目するバリデーションライブラリの特徴
TypeScriptで開発をしていると、「コンパイル時は型安全なのに、実行時にはどんなデータが来るかわからない」という問題に直面したことはありませんか?特にAPIレスポンスやユーザー入力のバリデーションでは、この課題が顕著に現れます。
Zodは、この課題を解決するTypeScript向けのスキーマ検証ライブラリです。コンパイル時の型チェックと実行時のバリデーションを統一的に扱えるのが最大の特徴です。
import { z } from 'zod';
// スキーマ定義
const UserSchema = z.object({
name: z.string(),
age: z.number().min(0),
email: z.string().email(),
});
// 型推論(自動でUser型が生成される)
type User = z.infer<typeof UserSchema>;
// バリデーション実行
const result = UserSchema.safeParse({
name: "田中太郎",
age: 25,
email: "[email protected]"
});
if (result.success) {
console.log(result.data); // 型安全にアクセス可能
}
Zodの主な特徴は以下の通りです:
- ゼロ依存関係: 他のライブラリに依存しない軽量な設計
- TypeScript優先: 型推論により手動での型定義が不要
- 豊富な検証ルール: 文字列、数値、配列、オブジェクトなど多様な型に対応
- エラーハンドリング: 詳細なエラーメッセージでデバッグが容易
Zodのインストールと基本的なセットアップ手順
Zodを始めるのは非常に簡単です。npmまたはyarnを使ってインストールできます。
# npm の場合
npm install zod
# yarn の場合
yarn add zod
# pnpm の場合
pnpm add zod
TypeScriptプロジェクトで使用する場合は、tsconfig.json
で適切な設定が必要です:
{
"compilerOptions": {
"strict": true,
"target": "ES2018",
"moduleResolution": "node"
}
}
基本的な使用方法は以下の通りです:
import { z } from 'zod';
// 基本的なスキーマ定義
const nameSchema = z.string();
const ageSchema = z.number();
// バリデーション実行
const nameResult = nameSchema.safeParse("太郎");
console.log(nameResult.success); // true
const ageResult = ageSchema.safeParse("25"); // 文字列を渡した場合
console.log(ageResult.success); // false
console.log(ageResult.error?.issues); // エラーの詳細
エラーハンドリングにはparse()
とsafeParse()
の2つの方法があります:
// parse(): エラー時に例外を投げる
try {
const data = nameSchema.parse(123); // エラーが発生
} catch (error) {
console.log("バリデーションエラー");
}
// safeParse(): エラー時に結果オブジェクトを返す(推奨)
const result = nameSchema.safeParse(123);
if (!result.success) {
console.log("バリデーション失敗:", result.error.issues);
}
スキーマ定義の基本パターンと型推論活用法
Zodの真価は、豊富なスキーマ定義パターンにあります。以下は実際のプロジェクトでよく使用されるパターンです。
基本型とバリデーション
import { z } from 'zod';
// 文字列のバリデーション
const nameSchema = z.string()
.min(1, "名前は必須です")
.max(50, "名前は50文字以内で入力してください");
// 数値のバリデーション
const ageSchema = z.number()
.int("整数で入力してください")
.min(0, "年齢は0以上で入力してください")
.max(120, "年齢は120以下で入力してください");
// メールアドレス
const emailSchema = z.string().email("正しいメールアドレスを入力してください");
// URL
const websiteSchema = z.string().url("正しいURLを入力してください");
オブジェクトスキーマと型推論
// ユーザー情報のスキーマ
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(0).optional(), // オプショナル
isActive: z.boolean().default(true), // デフォルト値
tags: z.array(z.string()), // 配列
profile: z.object({
bio: z.string().optional(),
location: z.string().optional(),
}).optional(),
});
// 型推論でTypeScript型を自動生成
type User = z.infer<typeof UserSchema>;
// 結果: {
// id: number;
// name: string;
// email: string;
// age?: number | undefined;
// isActive: boolean;
// tags: string[];
// profile?: { bio?: string; location?: string; } | undefined;
// }
配列とオブジェクトの高度なパターン
// 条件付きスキーマ
const ProductSchema = z.object({
type: z.enum(['digital', 'physical']),
name: z.string(),
price: z.number(),
}).refine((data) => {
if (data.type === 'physical' && data.price < 500) {
return false; // 物理商品は500円以上
}
return true;
}, {
message: "物理商品は500円以上で設定してください",
});
// 文字列の変換
const DateStringSchema = z.string()
.transform((str) => new Date(str))
.pipe(z.date());
// 使用例
const result = DateStringSchema.safeParse("2025-05-24");
if (result.success) {
console.log(result.data); // Date オブジェクト
}
フォームバリデーションでのZod実装例
実際のWebアプリケーションでは、フォームのバリデーションが頻繁に必要になります。Zodを使うことで型安全で効率的な実装が可能です。
基本的なフォームバリデーション
import { z } from 'zod';
// 会員登録フォームのスキーマ
const RegisterFormSchema = z.object({
username: z.string()
.min(3, "ユーザー名は3文字以上で入力してください")
.max(20, "ユーザー名は20文字以内で入力してください")
.regex(/^[a-zA-Z0-9_]+$/, "ユーザー名は英数字とアンダースコアのみ使用可能です"),
email: z.string()
.email("正しいメールアドレスを入力してください"),
password: z.string()
.min(8, "パスワードは8文字以上で入力してください")
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, "パスワードは大文字、小文字、数字を含む必要があります"),
confirmPassword: z.string(),
age: z.number()
.int("年齢は整数で入力してください")
.min(13, "13歳以上である必要があります"),
terms: z.boolean()
.refine(val => val === true, "利用規約に同意してください")
}).refine(data => data.password === data.confirmPassword, {
message: "パスワードが一致しません",
path: ["confirmPassword"], // エラーの対象フィールドを指定
});
type RegisterForm = z.infer<typeof RegisterFormSchema>;
React Hook Formとの連携
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<RegisterForm>({
resolver: zodResolver(RegisterFormSchema)
});
const onSubmit = (data: RegisterForm) => {
console.log("バリデーション成功:", data);
// API呼び出しなど
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("username")}
placeholder="ユーザー名"
/>
{errors.username && (
<p className="error">{errors.username.message}</p>
)}
<input
{...register("email")}
type="email"
placeholder="メールアドレス"
/>
{errors.email && (
<p className="error">{errors.email.message}</p>
)}
<input
{...register("password")}
type="password"
placeholder="パスワード"
/>
{errors.password && (
<p className="error">{errors.password.message}</p>
)}
<button type="submit">登録</button>
</form>
);
}
動的フォームバリデーション
// 商品カテゴリに応じて異なるバリデーション
const ProductFormSchema = z.discriminatedUnion('category', [
z.object({
category: z.literal('book'),
title: z.string().min(1),
author: z.string().min(1),
isbn: z.string().regex(/^\d{13}$/, "ISBNは13桁の数字で入力してください"),
price: z.number().min(0)
}),
z.object({
category: z.literal('electronics'),
name: z.string().min(1),
brand: z.string().min(1),
model: z.string().min(1),
warranty: z.number().min(0).max(10, "保証期間は10年以内で設定してください"),
price: z.number().min(0)
})
]);
type ProductForm = z.infer<typeof ProductFormSchema>;
API連携でのZodを使ったレスポンス検証
外部APIからのレスポンスは型安全性が保証されていません。Zodを使用することで、実行時にAPIレスポンスの型安全性を確保できます。
基本的なAPIレスポンス検証
import { z } from 'zod';
// APIレスポンスのスキーマ定義
const UserResponseSchema = z.object({
status: z.literal('success'),
data: z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().transform(str => new Date(str)),
profile: z.object({
avatar: z.string().url().optional(),
bio: z.string().optional(),
}).optional(),
}),
meta: z.object({
page: z.number(),
total: z.number(),
}).optional(),
});
type UserResponse = z.infer<typeof UserResponseSchema>;
// API呼び出し関数
async function fetchUser(userId: number): Promise<UserResponse> {
const response = await fetch(`/api/users/${userId}`);
const rawData = await response.json();
// レスポンスをバリデーション
const result = UserResponseSchema.safeParse(rawData);
if (!result.success) {
console.error('APIレスポンスの形式が不正です:', result.error.issues);
throw new Error('不正なAPIレスポンス');
}
return result.data; // 型安全な戻り値
}
エラーレスポンスの処理
// 成功とエラーを統一的に扱うスキーマ
const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.object({
users: z.array(UserResponseSchema.shape.data),
pagination: z.object({
current: z.number(),
total: z.number(),
hasNext: z.boolean(),
}),
}),
}),
z.object({
status: z.literal('error'),
error: z.object({
code: z.string(),
message: z.string(),
details: z.array(z.string()).optional(),
}),
}),
]);
type ApiResponse = z.infer<typeof ApiResponseSchema>;
async function fetchUsers(): Promise<ApiResponse> {
try {
const response = await fetch('/api/users');
const rawData = await response.json();
const result = ApiResponseSchema.safeParse(rawData);
if (!result.success) {
return {
status: 'error',
error: {
code: 'VALIDATION_ERROR',
message: 'APIレスポンスの形式が不正です',
details: result.error.issues.map(issue => issue.message),
},
};
}
return result.data;
} catch (error) {
return {
status: 'error',
error: {
code: 'NETWORK_ERROR',
message: 'ネットワークエラーが発生しました',
},
};
}
}
TypeSafeなAPI Client
// 汎用的なAPI Client
class TypeSafeApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async request<T>(
endpoint: string,
schema: z.ZodSchema<T>,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, options);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const rawData = await response.json();
const result = schema.safeParse(rawData);
if (!result.success) {
console.error('API validation error:', result.error.issues);
throw new Error('Invalid API response format');
}
return result.data;
}
}
// 使用例
const apiClient = new TypeSafeApiClient('https://api.example.com');
const user = await apiClient.request(
'/users/123',
UserResponseSchema,
{ method: 'GET' }
);
// userは完全に型安全
console.log(user.data.name); // string
console.log(user.data.createdAt); // Date
Next.js・ReactでのZod実践活用テクニック
Next.jsアプリケーションでZodを活用することで、クライアントサイドからサーバーサイドまで一貫した型安全性を実現できます。
Next.js API Routesでのバリデーション
// pages/api/users.ts または app/api/users/route.ts
import { z } from 'zod';
import { NextApiRequest, NextApiResponse } from 'next';
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(120),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// リクエストボディをバリデーション
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'バリデーションエラー',
details: result.error.issues
});
}
const { name, email, age } = result.data;
try {
// データベースに保存
const user = await createUser({ name, email, age });
res.status(201).json({ user });
} catch (error) {
res.status(500).json({ error: 'サーバーエラー' });
}
}
App Router(Server Actions)での活用
// app/actions/user-actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const CreateUserSchema = z.object({
name: z.string().min(1, "名前は必須です"),
email: z.string().email("正しいメールアドレスを入力してください"),
age: z.number().int().min(0),
});
export async function createUser(formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
age: parseInt(formData.get('age') as string),
};
const result = CreateUserSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.issues.reduce((acc, issue) => {
acc[issue.path[0] as string] = issue.message;
return acc;
}, {} as Record<string, string>)
};
}
try {
const user = await saveUser(result.data);
revalidatePath('/users');
return { success: true, user };
} catch (error) {
return { success: false, error: 'データベースエラー' };
}
}
環境変数のバリデーション
// 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(),
NODE_ENV: z.enum(['development', 'production', 'test']),
API_KEY: z.string().min(1),
});
export const env = EnvSchema.parse(process.env);
// 使用時
import { env } from '@/lib/env';
// env.DATABASE_URL は型安全にアクセス可能
カスタムフックでのバリデーション
// hooks/useFormValidation.ts
import { useState } from 'react';
import { z } from 'zod';
export function useFormValidation<T>(schema: z.ZodSchema<T>) {
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (data: unknown): data is T => {
const result = schema.safeParse(data);
if (!result.success) {
const errorMap = result.error.issues.reduce((acc, issue) => {
const path = issue.path.join('.');
acc[path] = issue.message;
return acc;
}, {} as Record<string, string>);
setErrors(errorMap);
return false;
}
setErrors({});
return true;
};
return { validate, errors, clearErrors: () => setErrors({}) };
}
// 使用例
function MyForm() {
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const { validate, errors } = useFormValidation(schema);
const handleSubmit = (formData: unknown) => {
if (validate(formData)) {
// バリデーション成功時の処理
console.log('フォーム送信:', formData);
}
};
return (
<form>
{/* フォーム要素 */}
{errors.name && <span>{errors.name}</span>}
{errors.email && <span>{errors.email}</span>}
</form>
);
}
まとめ
Zodを使用することで、TypeScriptプロジェクトにおいて以下のメリットが得られます:
- 型安全性の向上: コンパイル時と実行時の両方で型の整合性を保証
- 開発効率の向上: 型推論により手動での型定義が不要
- エラーハンドリングの改善: 詳細で分かりやすいエラーメッセージ
- 保守性の向上: スキーマ変更時の影響範囲が明確
Zodは学習コストが低く、既存のプロジェクトにも段階的に導入可能です。ぜひあなたのプロジェクトでも活用してみてください。
このトピックはこちらの書籍で勉強するのがおすすめ!
この記事の内容をさらに深く理解したい方におすすめの一冊です。実践的な知識を身につけたい方は、ぜひチェックしてみてください!
おすすめコンテンツ
おすすめTypeScript2025/5/16TypeScript非同期処理パターン完全ガイド:エラーハンドリングから並行処理まで
TypeScriptにおける非同期処理の基本から応用までを網羅。Promiseの使い方、async/awaitのベストプラクティス、エラーハンドリング、並行処理パターンまで実践的なコード例とともに解説...
続きを読む TypeScript2025/6/2TypeScriptとNode.jsでリアルタイムAPI設計完全ガイド:WebSocketから始めるスケーラブルなアプリケーション構築法
TypeScriptとNode.jsを使ったリアルタイムAPI設計の実践ガイド。WebSocketの基本実装からスケーラブルなアーキテクチャ設計まで、コード例豊富で初心者にもわかりやすく解説します。
続きを読む IT技術2025/5/4TypeScriptのType Guardsで型安全なコードを書く方法:初心者向け完全ガイド
TypeScriptのType Guards(型ガード)は、コードの型安全性を高め、バグを減らすための強力な機能です。このガイドでは、TypeScriptの型ガードの基本から応用まで、実際のコード例を...
続きを読む TypeScript2025/5/21TypeScriptのエラーハンドリングガイド:初心者でも理解できる基本と実践例
TypeScriptにおけるエラーハンドリングの基本から応用までを初心者向けに分かりやすく解説。例外処理の書き方、エラー型の定義、実践的なエラー設計パターンまで、具体的なコード例を交えて学べるガイド。
続きを読む TypeScript2025/6/2TypeScriptでGitHub Actionsカスタムアクション開発完全ガイド!CI/CDワークフローを効率化する実践的な作り方
TypeScriptを使ってGitHub Actionsのカスタムアクションを開発する方法を初心者でも理解できるよう詳しく解説します。実際のコード例とベストプラクティスで、あなたのCI/CDワークフロ...
続きを読む React2025/5/16TypeScriptでの型安全な状態管理:Zustandを使った実践的アプローチ
TypeScriptとZustandを組み合わせた型安全な状態管理の方法を学びましょう。シンプルでありながら強力な状態管理ライブラリZustandの基本から応用まで、実践的なコード例を交えて解説します...
続きを読む React2025/5/1Next.jsとTypeScriptで作る高速SSGブログ:初心者でも簡単に実装できる完全ガイド
Next.jsとTypeScriptを組み合わせたSSGブログの作り方を解説します。静的サイト生成の利点から実装方法、デプロイまで、初心者でも理解できるようステップバイステップで説明。コードサンプル付...
続きを読む Docker2025/5/20Docker環境でTypeScriptのホットリロードが効かない時の解決策
Docker環境でTypeScriptアプリケーションを開発しているとホットリロードが動作しない問題に遭遇することがあります。この記事では、その原因と具体的な解決方法を実践的なコード例とともに解説しま...
続きを読む