← ブログ一覧

Zod でランタイムバリデーションと型安全性を両立する実践ガイド

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

#TypeScript#validation#API#Next.js
Zod でランタイムバリデーションと型安全性を両立する実践ガイド

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 スキーマとの整合性を確認

次のステップ


受託開発会社 Yureate では、型安全性を重視した Web アプリケーション開発を支援しています。Zod を含むモダンな技術スタックでの開発・技術相談は、お問い合わせページからお気軽にご連絡ください。

この内容について相談する他の記事を見る