T-CREATOR

Zod で条件付きバリデーションを実装する方法(if/then/else パターン)

Zod で条件付きバリデーションを実装する方法(if/then/else パターン)

TypeScript でデータバリデーションを行う際、単純なルールだけでは対応できない複雑な条件分岐が必要になることがあります。Zod の条件付きバリデーション機能を使えば、フィールドの値に応じて動的にバリデーションルールを変更できるようになります。

本記事では、Zod の if​/​then​/​else パターンを使った条件付きバリデーションの実装方法を、具体例とともに詳しく解説いたします。

背景

現代の Web アプリケーションでは、ユーザーの入力内容に応じてフォームの検証ルールを動的に変更する必要が頻繁に発生します。このような動的なバリデーションは、ユーザーエクスペリエンスを向上させる重要な要素となっています。

フォームバリデーションにおける条件分岐の必要性を理解するため、以下の図でその構造を確認してみましょう。

mermaidflowchart TD
  input[ユーザー入力] -->|条件判定| condition{条件チェック}
  condition -->|条件A| ruleA[バリデーションルールA]
  condition -->|条件B| ruleB[バリデーションルールB]
  condition -->|条件C| ruleC[バリデーションルールC]
  ruleA --> result[バリデーション結果]
  ruleB --> result
  ruleC --> result

上図のように、入力値によって適用されるバリデーションルールが動的に変更される仕組みが求められています。

単純なバリデーションでは対応できないケース

従来の静的なバリデーションでは、以下のようなケースに対応することが困難でした。

#ケース
1フィールド値による必須項目の変更配送方法が「店舗受取」の場合、住所入力が不要
2値の範囲による異なる検証ルール年齢が18歳未満の場合、保護者同意が必須
3複数フィールドの相関関係支払い方法がクレジットカードの場合のみ、カード番号が必須

動的なフィールド検証の必要性

ユーザーの操作に応じてリアルタイムでバリデーションルールが変わることで、以下のメリットが得られます。

  • ユーザビリティの向上: 不要な入力を求めない
  • エラーの事前防止: 適切なタイミングでの検証実行
  • 開発効率の向上: 統一されたバリデーションロジック

課題

従来のバリデーション手法では、複雑な条件分岐を持つフォーム検証に対応する際に、いくつかの重要な課題が発生していました。

以下の図で、従来手法の限界を確認してみましょう。

mermaidstateDiagram-v2
  [*] --> StaticValidation: 従来手法
  StaticValidation --> LimitationA: 静的スキーマ
  StaticValidation --> LimitationB: 手動分岐処理
  LimitationA --> Problem1: メンテナンス困難
  LimitationB --> Problem2: コード複雑化
  Problem1 --> [*]
  Problem2 --> [*]

静的なスキーマの問題点

従来のバリデーションライブラリでは、スキーマが固定的であるため以下の問題が発生していました。

  • 柔軟性の不足: 条件に応じたルール変更が困難
  • コードの重複: 似たようなスキーマの複数定義が必要
  • 保守性の低下: 条件追加時の影響範囲が広範囲

複雑な条件分岐への対応困難

手動でバリデーション分岐を実装する際の課題として、以下が挙げられます。

typescript// 従来の手動分岐による実装例(課題のあるコード)
function validateUserForm(data: any) {
  const errors = [];
  
  // 複雑な条件分岐
  if (data.userType === 'premium') {
    if (!data.premiumCode || data.premiumCode.length < 8) {
      errors.push('プレミアムコードは8文字以上必要です');
    }
  } else if (data.userType === 'standard') {
    if (!data.email || !isValidEmail(data.email)) {
      errors.push('有効なメールアドレスが必要です');
    }
  }
  
  return errors;
}

上記のコードでは、条件が増えるたびにネストが深くなり、可読性とメンテナンス性が大幅に低下してしまいます。

解決策

Zod の条件付きバリデーション機能を使用することで、これらの課題を効率的に解決できます。特に z.if() メソッドと then​/​else チェーンを活用することで、宣言的で読みやすいバリデーションロジックを構築できます。

z.if() メソッドの仕組み

Zod の z.if() メソッドは、条件式を評価して動的にバリデーションスキーマを変更する機能を提供します。

typescriptimport { z } from 'zod';

// 基本的なz.if()の構文
const conditionalSchema = z.object({
  // 基本フィールド定義
}).and(
  z.if(
    // 条件式:この条件がtrueの場合
    z.object({ fieldName: z.literal('特定の値') }),
    // then: 条件がtrueの場合のスキーマ拡張
    z.object({ additionalField: z.string().min(1) })
  ).else(
    // else: 条件がfalseの場合のスキーマ拡張
    z.object({ alternativeField: z.string().optional() })
  )
);

このメソッドの動作フローを図で確認してみましょう。

mermaidflowchart LR
  input[入力データ] --> condition{z.if 条件評価}
  condition -->|true| then_schema[then スキーマ]
  condition -->|false| else_schema[else スキーマ]
  then_schema --> validation[バリデーション実行]
  else_schema --> validation
  validation --> result[結果返却]

then/else による条件分岐の実装

then()else() メソッドを組み合わせることで、条件に応じた詳細なバリデーションルールを定義できます。

typescript// then/elseの実装例
const userRegistrationSchema = z.object({
  name: z.string().min(1, '名前は必須です'),
  userType: z.enum(['standard', 'premium']),
  email: z.string().email('有効なメールアドレスを入力してください')
}).and(
  z.if(
    // プレミアムユーザーの条件
    z.object({ userType: z.literal('premium') }),
    // プレミアムユーザーの場合の追加バリデーション
    z.object({
      premiumCode: z.string()
        .min(8, 'プレミアムコードは8文字以上必要です')
        .regex(/^[A-Z0-9]+$/, 'プレミアムコードは英数字で入力してください')
    })
  ).else(
    // 一般ユーザーの場合の追加バリデーション
    z.object({
      premiumCode: z.string().optional()
    })
  )
);

このアプローチにより、以下の利点が得られます。

  • 宣言的な記述: 条件とバリデーションルールが明確に分離
  • 型安全性: TypeScript との完全な統合
  • 再利用性: スキーマコンポーネントの組み合わせが可能

具体例

実際のプロジェクトで活用できる、様々なパターンの条件付きバリデーション実装例を詳しく見ていきましょう。

基本的な if/then/else パターン

最も基本的な条件分岐パターンから始めましょう。以下は、ユーザータイプに応じて異なる検証を行う例です。

typescriptimport { z } from 'zod';

// 基本的なフィールド定義
const baseUserSchema = z.object({
  name: z.string().min(1, '名前を入力してください'),
  age: z.number().min(0, '年齢は0以上で入力してください'),
  userType: z.enum(['student', 'adult', 'senior'])
});
typescript// 条件付きバリデーションの実装
const userValidationSchema = baseUserSchema.and(
  z.if(
    // 学生の場合の条件
    z.object({ userType: z.literal('student') }),
    // 学生用の追加バリデーション
    z.object({
      studentId: z.string()
        .min(1, '学生IDは必須です')
        .regex(/^[0-9]{8}$/, '学生IDは8桁の数字で入力してください'),
      guardianConsent: z.boolean()
        .refine(val => val === true, '保護者の同意が必要です')
    })
  ).else(
    // 学生以外の場合
    z.object({
      studentId: z.string().optional(),
      guardianConsent: z.boolean().optional()
    })
  )
);
typescript// バリデーションの実行例
const validateUser = (userData: any) => {
  try {
    const validatedData = userValidationSchema.parse(userData);
    console.log('バリデーション成功:', validatedData);
    return { success: true, data: validatedData };
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.log('バリデーションエラー:', error.errors);
      return { success: false, errors: error.errors };
    }
    throw error;
  }
};

複数条件の組み合わせ

より複雑なシナリオでは、複数の条件を組み合わせる必要があります。以下は、年齢とユーザータイプの両方を考慮した例です。

typescript// 複数条件を考慮したスキーマ
const complexUserSchema = z.object({
  name: z.string().min(1),
  age: z.number().min(0),
  userType: z.enum(['standard', 'premium']),
  hasLicense: z.boolean()
}).and(
  z.if(
    // 18歳以上かつプレミアムユーザーの条件
    z.object({ 
      age: z.number().min(18),
      userType: z.literal('premium')
    }),
    // 成人プレミアムユーザー向けバリデーション
    z.object({
      premiumFeatures: z.array(z.string()).min(1, 'プレミアム機能を選択してください'),
      paymentMethod: z.enum(['creditcard', 'bank']),
      licenseVerification: z.string().optional()
    })
  ).else(
    z.if(
      // 18歳未満の条件
      z.object({ age: z.number().max(17) }),
      // 未成年者向けバリデーション
      z.object({
        parentEmail: z.string().email('保護者のメールアドレスが必要です'),
        parentConsent: z.boolean().refine(val => val === true)
      })
    ).else(
      // その他(18歳以上の一般ユーザー)
      z.object({
        premiumFeatures: z.array(z.string()).optional(),
        paymentMethod: z.enum(['creditcard', 'bank']).optional()
      })
    )
  )
);

以下の図で、複数条件の判定フローを確認できます。

mermaidflowchart TD
  start[ユーザー入力] --> age_check{年齢 >= 18?}
  age_check -->|No| minor[未成年者処理]
  age_check -->|Yes| type_check{プレミアム?}
  type_check -->|Yes| premium[成人プレミアム処理]
  type_check -->|No| standard[成人一般処理]
  
  minor --> minor_validation[保護者同意必須]
  premium --> premium_validation[プレミアム機能選択]
  standard --> standard_validation[基本バリデーション]

ネストした条件の実装

さらに高度なケースでは、条件をネストして複雑なビジネスロジックを表現できます。

typescript// ネストした条件付きバリデーション
const nestedConditionalSchema = z.object({
  orderType: z.enum(['pickup', 'delivery', 'express']),
  customerType: z.enum(['regular', 'vip']),
  amount: z.number().min(0)
}).and(
  z.if(
    // 配送注文の場合
    z.object({ orderType: z.enum(['delivery', 'express']) }),
    // 配送情報が必要
    z.object({
      address: z.object({
        street: z.string().min(1, '住所を入力してください'),
        city: z.string().min(1, '市区町村を入力してください'),
        postalCode: z.string().regex(/^\d{7}$/, '郵便番号は7桁で入力してください')
      })
    }).and(
      z.if(
        // エクスプレス配送の場合
        z.object({ orderType: z.literal('express') }),
        // エクスプレス配送の追加条件
        z.object({
          deliveryTime: z.enum(['morning', 'afternoon', 'evening']),
          phone: z.string().regex(/^[0-9-]+$/, '電話番号を正しい形式で入力してください')
        }).and(
          z.if(
            // VIPかつエクスプレスの場合
            z.object({ customerType: z.literal('vip') }),
            // VIP特典の追加オプション
            z.object({
              specialRequest: z.string().optional(),
              priorityHandling: z.boolean().default(true)
            })
          )
        )
      ).else(
        // 通常配送の場合
        z.object({
          deliveryTime: z.enum(['standard']).optional(),
          phone: z.string().optional()
        })
      )
    )
  ).else(
    // 店舗受取の場合
    z.object({
      storeId: z.string().min(1, '受取店舗を選択してください'),
      pickupDate: z.string().datetime('受取日時を選択してください')
    })
  )
);
typescript// 実用的な使用例
const processOrder = (orderData: any) => {
  const validation = nestedConditionalSchema.safeParse(orderData);
  
  if (!validation.success) {
    return {
      success: false,
      errors: validation.error.errors.map(err => ({
        field: err.path.join('.'),
        message: err.message
      }))
    };
  }
  
  const validatedOrder = validation.data;
  console.log('注文データの検証が完了しました:', validatedOrder);
  
  return { success: true, order: validatedOrder };
};

これらの実装パターンにより、以下の要点が図で理解できます:

  • 段階的な条件判定: 外側から内側へ順番に条件を評価
  • スキーマの合成: .and() メソッドによるスキーマの組み合わせ
  • 型安全性の保持: すべての条件パターンで型推論が正確に動作

まとめ

Zod の条件付きバリデーション機能を活用することで、複雑なビジネスルールを含む動的なフォーム検証を効率的に実装できるようになります。

本記事でご紹介した if​/​then​/​else パターンの主なメリットをまとめると以下のようになります。

#メリット詳細
1宣言的な記述条件とバリデーションルールが明確に分離され、可読性が向上
2型安全性TypeScript との完全な統合により、コンパイル時のエラー検出が可能
3保守性条件追加や変更時の影響範囲が限定的で、メンテナンスが容易
4再利用性スキーマコンポーネントの組み合わせにより、柔軟な構成が可能

条件付きバリデーションを導入する際は、以下の点にご注意ください。

  • 段階的な実装: まず基本的なパターンから始めて、徐々に複雑な条件を追加
  • テストの充実: 各条件パターンに対する十分なテストケースの作成
  • エラーハンドリング: ユーザーにとって理解しやすいエラーメッセージの設計

Zod の条件付きバリデーションをマスターすることで、ユーザーエクスペリエンスを大幅に向上させる高品質なフォーム検証システムを構築できるでしょう。

関連リンク