Tasuke Hubのロゴ

ITを中心に困っている人を助けるメディア

分かりやすく解決策を提供することで、あなたの困ったをサポート。 全ての人々がスムーズに生活できる世界を目指します。

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は学習コストが低く、既存のプロジェクトにも段階的に導入可能です。ぜひあなたのプロジェクトでも活用してみてください。

TH

Tasuke Hub管理人

東証プライム市場上場企業エンジニア

情報系修士卒業後、大手IT企業にてフルスタックエンジニアとして活躍。 Webアプリケーション開発からクラウドインフラ構築まで幅広い技術に精通し、 複数のプロジェクトでリードエンジニアを担当。 技術ブログやオープンソースへの貢献を通じて、日本のIT技術コミュニティに積極的に関わっている。

🎓情報系修士🏢東証プライム上場企業💻フルスタックエンジニア📝技術ブログ執筆者

このトピックはこちらの書籍で勉強するのがおすすめ!

この記事の内容をさらに深く理解したい方におすすめの一冊です。実践的な知識を身につけたい方は、ぜひチェックしてみてください!

おすすめ記事

おすすめコンテンツ