DT 技術解説 TypeScriptのZodバリデーションライブラリ完全ガイド

TypeScriptのZodバリデーションライブラリ完全ガイド

TypeScriptプロジェクトにおけるZodを使ったスキーマバリデーションの基本から実践的な活用法まで詳しく解説します。

約5分で読めます
技術記事
実践的

この記事のポイント

TypeScriptプロジェクトにおけるZodを使ったスキーマバリデーションの基本から実践的な活用法まで詳しく解説します。

この記事では、実践的なアプローチで技術的な課題を解決する方法を詳しく解説します。具体的なコード例とともに、ベストプラクティスを学ぶことができます。

Zodは、TypeScriptファーストなスキーマバリデーションライブラリです。型安全性を保ちながら実行時バリデーションを行えるため、フロントエンドからバックエンドまで幅広く活用されています。

Zodとは

Zodは以下の特徴を持つバリデーションライブラリです:

  • TypeScript ファースト: TypeScriptの型システムと完全に統合
  • ゼロ依存: 外部ライブラリに依存しない軽量設計
  • チェーン可能なAPI: 直感的でReadableなスキーマ定義
  • 型推論: スキーマから自動的にTypeScript型を生成

基本的なセットアップ

インストール

npm install zod
# または
yarn add zod

基本的な使用法

import { z } from 'zod';

// スキーマ定義
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0).max(120),
});

// 型の推論
type User = z.infer<typeof UserSchema>;

// バリデーション実行
const userData = {
  id: 1,
  name: "田中太郎",
  email: "tanaka@example.com",
  age: 30,
};

try {
  const validUser = UserSchema.parse(userData);
  console.log(validUser); // 型安全なデータ
} catch (error) {
  console.error(error.issues); // バリデーションエラー詳細
}

基本的なスキーマタイプ

プリミティブ型

import { z } from 'zod';

// 文字列
const stringSchema = z.string();
const nonEmptyString = z.string().min(1);
const emailSchema = z.string().email();
const urlSchema = z.string().url();

// 数値
const numberSchema = z.number();
const positiveNumber = z.number().positive();
const integerSchema = z.number().int();

// 真偽値
const booleanSchema = z.boolean();

// 日付
const dateSchema = z.date();
const futureDate = z.date().min(new Date());

複合型

// 配列
const stringArraySchema = z.array(z.string());
const numberArraySchema = z.array(z.number()).min(1).max(10);

// オブジェクト
const PersonSchema = z.object({
  name: z.string(),
  age: z.number().optional(),
  hobbies: z.array(z.string()),
});

// ユニオン型
const StatusSchema = z.union([
  z.literal('pending'),
  z.literal('approved'),
  z.literal('rejected'),
]);

// Enum
const ColorSchema = z.enum(['red', 'green', 'blue']);

実践的なバリデーション例

APIレスポンスの検証

const ApiResponseSchema = z.object({
  success: z.boolean(),
  data: z.object({
    users: z.array(z.object({
      id: z.number(),
      name: z.string(),
      email: z.string().email(),
      createdAt: z.string().datetime(),
    })),
    total: z.number(),
    page: z.number(),
  }),
  error: z.string().nullable(),
});

async function fetchUsers(page: number) {
  const response = await fetch(`/api/users?page=${page}`);
  const rawData = await response.json();
  
  // APIレスポンスを検証
  const result = ApiResponseSchema.safeParse(rawData);
  
  if (!result.success) {
    throw new Error('Invalid API response format');
  }
  
  return result.data; // 型安全なデータ
}

フォームバリデーション

const RegisterFormSchema = z.object({
  username: z.string()
    .min(3, { message: "ユーザー名は3文字以上で入力してください" })
    .max(20, { message: "ユーザー名は20文字以下で入力してください" })
    .regex(/^[a-zA-Z0-9_]+$/, { message: "英数字とアンダースコアのみ使用可能です" }),
  
  email: z.string()
    .email({ message: "有効なメールアドレスを入力してください" }),
  
  password: z.string()
    .min(8, { message: "パスワードは8文字以上で入力してください" })
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { 
      message: "大文字、小文字、数字を含む必要があります" 
    }),
  
  confirmPassword: z.string(),
  
  birthDate: z.string()
    .datetime()
    .refine((date) => {
      const age = new Date().getFullYear() - new Date(date).getFullYear();
      return age >= 18;
    }, { message: "18歳以上である必要があります" }),
  
  terms: z.boolean()
    .refine((val) => val === true, { 
      message: "利用規約に同意してください" 
    }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードが一致しません",
  path: ["confirmPassword"],
});

type RegisterForm = z.infer<typeof RegisterFormSchema>;

高度な機能

カスタムバリデーション

const CustomSchema = z.object({
  phoneNumber: z.string().refine((val) => {
    // 日本の電話番号形式をチェック
    const phoneRegex = /^(\+81|0)\d{1,4}-?\d{1,4}-?\d{4}$/;
    return phoneRegex.test(val);
  }, {
    message: "有効な電話番号を入力してください",
  }),
  
  uniqueEmail: z.string().email().refine(async (email) => {
    // データベースでユニークチェック(非同期)
    const exists = await checkEmailExists(email);
    return !exists;
  }, {
    message: "このメールアドレスは既に使用されています",
  }),
});

条件付きスキーマ

const ConditionalSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('personal'),
    name: z.string(),
    age: z.number(),
  }),
  z.object({
    type: z.literal('business'),
    companyName: z.string(),
    taxId: z.string(),
  }),
]);

// 使用例
const personalData = {
  type: 'personal' as const,
  name: '田中太郎',
  age: 30,
};

const businessData = {
  type: 'business' as const,
  companyName: '株式会社サンプル',
  taxId: '123456789',
};

データ変換

const TransformSchema = z.object({
  price: z.string().transform((val) => parseFloat(val)),
  tags: z.string().transform((val) => val.split(',').map(tag => tag.trim())),
  isActive: z.string().transform((val) => val === 'true'),
  createdAt: z.string().transform((val) => new Date(val)),
});

const input = {
  price: "1299.99",
  tags: "typescript, zod, validation",
  isActive: "true",
  createdAt: "2024-12-26T10:00:00Z",
};

const result = TransformSchema.parse(input);
// result: {
//   price: 1299.99,
//   tags: ['typescript', 'zod', 'validation'],
//   isActive: true,
//   createdAt: Date object
// }

フレームワーク連携

React Hook Formとの連携

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const FormSchema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.string().email("有効なメールアドレスを入力してください"),
});

type FormData = z.infer<typeof FormSchema>;

function MyForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(FormSchema),
  });

  const onSubmit = (data: FormData) => {
    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>}
      
      <button type="submit">送信</button>
    </form>
  );
}

Next.js API Routesでの使用

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const userData = CreateUserSchema.parse(body);
    
    // データベース操作
    const user = await createUser(userData);
    
    return NextResponse.json({ success: true, user });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { success: false, errors: error.issues },
        { status: 400 }
      );
    }
    
    return NextResponse.json(
      { success: false, message: 'Internal server error' },
      { status: 500 }
    );
  }
}

パフォーマンスとベストプラクティス

スキーマの再利用

// 基本スキーマ
const BaseUserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// 拡張スキーマ
const CreateUserSchema = BaseUserSchema.omit({ id: true });
const UpdateUserSchema = BaseUserSchema.partial();
const UserWithTimestamps = BaseUserSchema.extend({
  createdAt: z.date(),
  updatedAt: z.date(),
});

エラーハンドリング

function validateUserData(data: unknown) {
  const result = UserSchema.safeParse(data);
  
  if (!result.success) {
    // 詳細なエラー情報を取得
    const errors = result.error.issues.map(issue => ({
      field: issue.path.join('.'),
      message: issue.message,
      code: issue.code,
    }));
    
    return { success: false, errors };
  }
  
  return { success: true, data: result.data };
}

まとめ

Zodは TypeScript プロジェクトにおいて、型安全性を保ちながら実行時バリデーションを実現する優れたライブラリです。

主な利点:

  • 🛡️ 型安全性: コンパイル時と実行時の両方で型安全性を保証
  • 🚀 開発体験: 直感的なAPIと優れた型推論
  • 🔧 柔軟性: カスタムバリデーションと変換機能
  • 🎯 エコシステム: React Hook Form、tRPCなどとの優れた連携

フォームバリデーション、API通信、設定ファイルの検証など、様々な場面でZodを活用して、より堅牢なTypeScriptアプリケーションを構築しましょう。