T-CREATOR

Zod で非同期バリデーション(async)を実装する方法

Zod で非同期バリデーション(async)を実装する方法

モダンな Web 開発において、フォームバリデーションは単純な文字列チェックだけでは済まなくなりました。ユーザー名の重複確認やメールアドレスの存在確認など、外部 API やデータベースとの連携が必要な場面が増えています。

TypeScript のスキーマバリデーションライブラリとして人気の高い Zod ですが、標準機能だけでは非同期処理に対応できません。しかし、Zod の refine メソッドを活用することで、Promise ベースの非同期バリデーションを実装できるのです。

本記事では、Zod での非同期バリデーション実装について、基本的な仕組みから実際のユースケースまで、段階的に解説いたします。

背景

Zod の標準的なバリデーションの仕組み

Zod は、TypeScript で型安全なスキーマバリデーションを提供するライブラリです。通常のバリデーションは同期的に実行されます。

以下は基本的な Zod バリデーションの例です:

typescriptimport { z } from 'zod';

// 基本的なスキーマ定義
const userSchema = z.object({
  name: z
    .string()
    .min(2, 'ユーザー名は2文字以上で入力してください'),
  email: z
    .string()
    .email('正しいメールアドレスを入力してください'),
  age: z.number().min(18, '18歳以上である必要があります'),
});

このようなバリデーションは、入力値に対して即座に結果を返すため、処理が高速で UI の応答性が良いという特徴があります。

typescript// 同期バリデーションの実行
const validateUser = (userData: unknown) => {
  try {
    const validatedData = userSchema.parse(userData);
    console.log('バリデーション成功:', validatedData);
    return { success: true, data: validatedData };
  } catch (error) {
    console.log('バリデーションエラー:', error);
    return { success: false, error };
  }
};

同期処理と非同期処理の違い

同期処理では、コードが順次実行され、一つの処理が完了するまで次の処理は待機状態となります。一方、非同期処理では、時間のかかる処理(API 呼び出しなど)を並行して実行できます。

Zod のバリデーション処理における違いを図で表すと以下のようになります:

mermaidflowchart TD
  input[入力データ] --> sync[同期バリデーション]
  sync --> result1[即座に結果返却]

  input2[入力データ] --> async[非同期バリデーション]
  async --> api[API/DB問い合わせ]
  api --> wait[待機時間]
  wait --> result2[結果返却]

  style sync fill:#e1f5fe
  style async fill:#fff3e0
  style api fill:#fce4ec

この図から分かるように、非同期バリデーションでは外部リソースへの問い合わせが必要になるため、待機時間が発生します。

非同期バリデーションが必要になるケース

現代の Web アプリケーションでは、以下のようなケースで非同期バリデーションが求められます:

項目用途実装例
ユーザー名重複確認新規登録時の重複チェック​/​api​/​users​/​check-username
メールアドレス存在確認有効性・到達可能性の確認メール配信 API 連携
在庫確認EC サイトでの商品購入前チェック在庫管理システム連携
権限確認ユーザーの操作権限チェック認証・認可システム連携
地域・郵便番号確認住所入力時の妥当性確認郵便番号 API との連携

これらの要件に対応するため、Zod での非同期バリデーション実装が必要となるのです。

課題

標準的な Zod では対応できない非同期処理

Zod の標準的なバリデーションメソッドは、すべて同期的に動作するように設計されています。以下のような制限があります:

typescript// これは動作しません - async/await を直接使用できない
const invalidSchema = z.object({
  username: z.string().refine(async (val) => {
    // Error: refine関数内でasyncは直接サポートされていない
    const response = await fetch(
      `/api/check-username/${val}`
    );
    return response.ok;
  }),
});

この制限により、外部 API やデータベースとの連携が必要なバリデーションを実装する際に課題が生じます。

API 呼び出しやデータベース検証の必要性

モダンな Web アプリケーションでは、以下のような検証要件が一般的です:

typescript// ユーザー登録フォームでの要件例
interface RegistrationRequirements {
  username: string; // データベースで重複チェックが必要
  email: string; // メール配信サービスで有効性確認が必要
  companyCode: string; // 企業マスタAPIで存在確認が必要
}

これらの要件を満たすためには、必然的に非同期処理が必要となります。

パフォーマンスとユーザビリティの両立

非同期バリデーションを実装する際は、以下の課題に対処する必要があります:

mermaidflowchart LR
  user[ユーザー入力] --> validation[バリデーション処理]
  validation --> api1[API呼び出し1]
  validation --> api2[API呼び出し2]
  validation --> api3[API呼び出し3]

  api1 --> wait1[待機時間]
  api2 --> wait2[待機時間]
  api3 --> wait3[待機時間]

  wait1 --> result[結果統合]
  wait2 --> result
  wait3 --> result

  result --> feedback[ユーザーフィードバック]

  style wait1 fill:#ffcdd2
  style wait2 fill:#ffcdd2
  style wait3 fill:#ffcdd2

主な課題として以下が挙げられます:

  • レスポンス時間の増加: API 呼び出しによる待機時間
  • ネットワークエラーの処理: 通信障害時の適切な対応
  • ユーザー体験の低下: 長時間の待機によるフラストレーション
  • リソース消費: 大量の API 呼び出しによるサーバー負荷

これらの課題を解決するため、適切な非同期バリデーション実装パターンが必要となります。

解決策

z.refine() メソッドによる非同期バリデーション

Zod では、z.refine() メソッドに Promise を返す関数を渡すことで、非同期バリデーションを実装できます。

基本的な実装パターンは以下の通りです:

typescriptimport { z } from 'zod';

// 非同期バリデーション関数の定義
const checkUsernameAvailability = async (
  username: string
): Promise<boolean> => {
  try {
    const response = await fetch(
      `/api/users/check/${username}`
    );
    const data = await response.json();
    return data.available;
  } catch (error) {
    // ネットワークエラー時は true を返して処理を続行
    console.error('Username check failed:', error);
    return true;
  }
};

この関数を使用してスキーマを定義します:

typescript// 非同期バリデーションを含むスキーマ
const userRegistrationSchema = z.object({
  username: z
    .string()
    .min(3, 'ユーザー名は3文字以上で入力してください')
    .refine(
      (username) => checkUsernameAvailability(username),
      {
        message: 'このユーザー名は既に使用されています',
      }
    ),
  email: z
    .string()
    .email('正しいメールアドレスを入力してください'),
});

Promise を返すカスタムバリデーション関数

より複雑な非同期バリデーションを実装する場合は、カスタムバリデーション関数を作成します:

typescript// 複数の条件をチェックする非同期バリデーション
const validateEmailComprehensive = async (
  email: string
): Promise<boolean> => {
  // Step 1: 基本的なフォーマットチェック(同期)
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    return false;
  }

  // Step 2: ドメイン存在確認(非同期)
  try {
    const domainResponse = await fetch(
      `/api/validate/domain`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      }
    );

    if (!domainResponse.ok) {
      return false;
    }

    const domainData = await domainResponse.json();
    return domainData.valid;
  } catch (error) {
    console.error('Domain validation failed:', error);
    return true; // エラー時は検証をスキップ
  }
};

このバリデーション関数をスキーマに組み込みます:

typescriptconst contactSchema = z.object({
  email: z
    .string()
    .email('メールアドレスの形式が正しくありません')
    .refine((email) => validateEmailComprehensive(email), {
      message:
        'このメールアドレスは無効またはアクセスできません',
    }),
});

エラーハンドリングとメッセージ設定

非同期バリデーションでは、適切なエラーハンドリングが重要です:

typescript// 詳細なエラー情報を含むバリデーション関数
const validateWithDetailedErrors = async (
  value: string
): Promise<boolean> => {
  try {
    const response = await fetch('/api/validate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ value }),
      signal: AbortSignal.timeout(5000), // 5秒でタイムアウト
    });

    if (!response.ok) {
      throw new Error(
        `HTTP ${response.status}: ${response.statusText}`
      );
    }

    const result = await response.json();
    return result.valid;
  } catch (error) {
    if (error instanceof Error) {
      console.error('Validation error:', error.message);
    }
    // エラー時のフォールバック処理
    return true;
  }
};

カスタムエラーメッセージの設定も可能です:

typescriptconst schemaWithCustomErrors = z.object({
  data: z.string().refine(validateWithDetailedErrors, {
    message:
      'サーバーでの検証に失敗しました。しばらく待ってから再試行してください',
  }),
});

非同期バリデーションの実行時は、parseAsync メソッドを使用します:

typescript// 非同期バリデーションの実行
const validateAsync = async (inputData: unknown) => {
  try {
    const validatedData =
      await schemaWithCustomErrors.parseAsync(inputData);
    console.log('バリデーション成功:', validatedData);
    return { success: true, data: validatedData };
  } catch (error) {
    console.log('バリデーションエラー:', error);
    return { success: false, error };
  }
};

これらの基本パターンを理解することで、様々な非同期バリデーション要件に対応できるようになります。

具体例

ユーザー名重複チェックの実装

ユーザー登録システムで最も一般的な非同期バリデーションの一つが、ユーザー名の重複チェックです。

まず、API エンドポイントとの通信を行う関数を作成します:

typescript// ユーザー名重複チェック用のAPI関数
const checkUsernameExists = async (
  username: string
): Promise<boolean> => {
  try {
    const response = await fetch(
      `/api/users/check-username`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ username }),
      }
    );

    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`);
    }

    const result = await response.json();
    return !result.exists; // 存在しない場合は true(利用可能)
  } catch (error) {
    console.error('Username check failed:', error);
    return true; // エラー時は利用可能として処理を続行
  }
};

デバウンス機能を追加して、ユーザーの入力中に過度な API 呼び出しを防ぎます:

typescript// デバウンス機能付きのチェック関数
class UsernameValidator {
  private timeoutId: NodeJS.Timeout | null = null;
  private cache = new Map<string, boolean>();

  async validateWithDebounce(
    username: string,
    delay = 500
  ): Promise<boolean> {
    // キャッシュから結果を取得
    if (this.cache.has(username)) {
      return this.cache.get(username)!;
    }

    // 既存のタイムアウトをクリア
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
    }

    return new Promise((resolve) => {
      this.timeoutId = setTimeout(async () => {
        const isAvailable = await checkUsernameExists(
          username
        );
        this.cache.set(username, isAvailable);
        resolve(isAvailable);
      }, delay);
    });
  }
}

const usernameValidator = new UsernameValidator();

このバリデーターを Zod スキーマに組み込みます:

typescriptconst userRegistrationSchema = z.object({
  username: z
    .string()
    .min(3, 'ユーザー名は3文字以上で入力してください')
    .max(20, 'ユーザー名は20文字以下で入力してください')
    .regex(
      /^[a-zA-Z0-9_]+$/,
      '英数字とアンダースコアのみ使用できます'
    )
    .refine(
      (username) =>
        usernameValidator.validateWithDebounce(username),
      {
        message: 'このユーザー名は既に使用されています',
      }
    ),
  email: z
    .string()
    .email('正しいメールアドレスを入力してください'),
  password: z
    .string()
    .min(8, 'パスワードは8文字以上で入力してください'),
});

メールアドレス存在確認

メールアドレスの存在確認は、より複雑なバリデーションプロセスが必要です:

typescript// メールアドレス検証のための複数ステップ関数
const validateEmailExistence = async (
  email: string
): Promise<boolean> => {
  const [localPart, domain] = email.split('@');

  // Step 1: ドメインのMXレコード確認
  const domainValid = await checkDomainMX(domain);
  if (!domainValid) {
    return false;
  }

  // Step 2: 使い捨てメールアドレスのチェック
  const isDisposable = await checkDisposableEmail(domain);
  if (isDisposable) {
    return false;
  }

  // Step 3: 既存ユーザーとの重複チェック
  const isExistingUser = await checkExistingEmail(email);
  return !isExistingUser;
};

// MXレコード確認関数
const checkDomainMX = async (
  domain: string
): Promise<boolean> => {
  try {
    const response = await fetch(
      `/api/email/check-domain`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ domain }),
      }
    );

    const result = await response.json();
    return result.hasValidMX;
  } catch (error) {
    console.error('Domain MX check failed:', error);
    return true; // エラー時は有効として処理
  }
};

使い捨てメールアドレスの検証:

typescript// 使い捨てメールアドレスのチェック
const checkDisposableEmail = async (
  domain: string
): Promise<boolean> => {
  try {
    const response = await fetch(
      `/api/email/check-disposable`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ domain }),
      }
    );

    const result = await response.json();
    return result.isDisposable;
  } catch (error) {
    console.error('Disposable email check failed:', error);
    return false; // エラー時は使い捨てではないとして処理
  }
};

// 既存ユーザーとの重複チェック
const checkExistingEmail = async (
  email: string
): Promise<boolean> => {
  try {
    const response = await fetch(`/api/users/check-email`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });

    const result = await response.json();
    return result.exists;
  } catch (error) {
    console.error('Email existence check failed:', error);
    return false; // エラー時は存在しないとして処理
  }
};

API レスポンス検証

外部 API からのレスポンスを検証する場合の実装例です:

typescript// API レスポンスの構造を定義
const apiResponseSchema = z.object({
  success: z.boolean(),
  data: z.object({
    id: z.number(),
    name: z.string(),
    status: z.enum(['active', 'inactive', 'pending']),
  }),
  timestamp: z.string().datetime(),
});

// API レスポンス検証関数
const validateApiResponse = async (
  endpoint: string,
  payload: unknown
): Promise<boolean> => {
  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      return false;
    }

    const responseData = await response.json();

    // Zod でレスポンス構造を検証
    const validatedResponse =
      apiResponseSchema.parse(responseData);

    // ビジネスロジックに基づく追加検証
    return (
      validatedResponse.success &&
      validatedResponse.data.status === 'active'
    );
  } catch (error) {
    console.error('API response validation failed:', error);
    return false;
  }
};

フォーム全体での使用例:

typescript// 複数の非同期バリデーションを組み合わせたスキーマ
const comprehensiveFormSchema = z.object({
  username: z
    .string()
    .min(3)
    .refine(
      (val) => usernameValidator.validateWithDebounce(val),
      { message: 'ユーザー名が既に使用されています' }
    ),
  email: z
    .string()
    .email()
    .refine((val) => validateEmailExistence(val), {
      message: 'このメールアドレスは使用できません',
    }),
  apiKey: z
    .string()
    .refine(
      (val) =>
        validateApiResponse('/api/validate-key', {
          key: val,
        }),
      { message: 'APIキーが無効です' }
    ),
});

// フォームバリデーションの実行
const handleFormSubmission = async (formData: unknown) => {
  try {
    const validatedData =
      await comprehensiveFormSchema.parseAsync(formData);
    console.log(
      '全てのバリデーションが成功しました:',
      validatedData
    );
    return { success: true, data: validatedData };
  } catch (error) {
    console.error('バリデーションエラー:', error);
    return { success: false, error };
  }
};

これらの具体例では、実際のプロダクション環境で遭遇する様々な非同期バリデーション要件をカバーしています。各例では、エラーハンドリング、パフォーマンス最適化、ユーザビリティの向上を考慮した実装パターンを示しています。

まとめ

Zod での非同期バリデーション実装について、基本的な仕組みから実践的な応用例まで詳しく解説いたしました。

重要なポイント

基本実装について

  • z.refine() メソッドに Promise を返す関数を渡すことで非同期バリデーションが可能
  • parseAsync() メソッドを使用して非同期バリデーションを実行
  • 適切なエラーハンドリングとフォールバック処理が必須

パフォーマンス最適化

  • デバウンス機能による API 呼び出し頻度の制御
  • キャッシュ機能による重複リクエストの回避
  • タイムアウト設定による応答性の確保

実用的な応用

  • ユーザー名重複チェックでのリアルタイム検証
  • メールアドレスの多段階検証(MX レコード、使い捨てチェック、重複確認)
  • 外部 API レスポンスの構造・内容検証

実装時の注意点

以下の点に注意して実装することで、堅牢で使いやすい非同期バリデーション機能を構築できます:

注意点対策
ネットワークエラー対応try-catch での適切なエラーハンドリング
パフォーマンス低下デバウンス・キャッシュ・タイムアウト設定
ユーザー体験ローディング表示・適切なエラーメッセージ
セキュリティ入力値のサニタイズ・レート制限

非同期バリデーションは、モダンな Web アプリケーションにおいて必要不可欠な機能です。Zod の柔軟性を活用することで、型安全性を保ちながら複雑なバリデーション要件にも対応できるでしょう。

実装の際は、ユーザビリティとパフォーマンスのバランスを考慮し、段階的にアプローチすることをお勧めいたします。

関連リンク