T-CREATOR

【徹底解説】Zod の refine と superRefine の違いと実践活用シーン

【徹底解説】Zod の refine と superRefine の違いと実践活用シーン

TypeScript プロジェクトでデータバリデーションを行う際、Zod は非常に強力なライブラリですね。特に、標準的なバリデーション機能では対応できない複雑な検証ロジックを実装する際に、refinesuperRefine という2つの重要なメソッドが用意されています。

しかし、これらの違いや使い分けについて迷ったことはありませんか?今回は、refinesuperRefine の根本的な違いから実践的な活用シーンまで、初心者の方にもわかりやすく解説していきます。

背景

Zod におけるバリデーション基本概念

Zod は TypeScript ファーストのスキーマバリデーションライブラリです。基本的なバリデーションは .string().number().email() などの組み込みメソッドで対応できますが、実際の開発では、より複雑な業務要件に対応する必要があります。

以下の図は、Zod におけるバリデーションの全体像を示しています。

mermaidflowchart TD
    input[入力データ] --> basic[基本バリデーション]
    basic --> pass1{基本チェック<br/>通過?}
    pass1 -->|Yes| custom[カスタムバリデーション]
    pass1 -->|No| error1[基本エラー]
    custom --> method{カスタム<br/>メソッド選択}
    method --> refine[refine]
    method --> superRefine[superRefine]
    refine --> pass2{カスタムチェック<br/>通過?}
    superRefine --> pass3{カスタムチェック<br/>通過?}
    pass2 -->|Yes| success[検証成功]
    pass2 -->|No| error2[refine エラー]
    pass3 -->|Yes| success
    pass3 -->|No| error3[superRefine エラー]

図で理解できる要点:

  • 基本バリデーションを通過したデータが、カスタムバリデーションに進む
  • refine と superRefine は、どちらもカスタムバリデーションの手法
  • エラーハンドリングの方法に違いがある

refine と superRefine の位置づけ

Zod では、組み込みバリデーションだけでは対応できない複雑な検証ロジックを実装するために、2つのアプローチが提供されています。

typescript// 基本的なバリデーション
const basicSchema = z.object({
  email: z.string().email(),
  age: z.number().min(0)
});
typescript// カスタムバリデーションが必要なケース
const customSchema = z.object({
  email: z.string().email(),
  age: z.number().min(0)
}).refine(/* カスタムロジック */);

refine は最もシンプルなカスタムバリデーション手法で、true/false を返すことで検証の成否を判定します。一方、superRefine はより高度な制御が可能で、複数のエラーを同時に管理したり、詳細なエラー情報を設定できます。

カスタムバリデーションの必要性

実際の開発では、以下のような複雑な要件に対応する必要があります:

#要件例基本バリデーションカスタムバリデーション
1パスワードの強度チェック
2複数フィールド間の整合性
3外部 API との照合
4ビジネスルールに基づく検証
5条件付きバリデーション

これらの要件を満たすために、refinesuperRefine というカスタムバリデーション機能が重要な役割を果たすのです。

課題

基本バリデーションの限界

Zod の基本バリデーションは非常に優秀ですが、以下のような制約があります。

typescript// 基本バリデーションの限界例
const userSchema = z.object({
  username: z.string().min(3).max(20),
  password: z.string().min(8),
  confirmPassword: z.string().min(8)
});

// この書き方では、password と confirmPassword の一致をチェックできない

基本バリデーションでは、単一フィールドの検証は可能ですが、複数フィールド間の関係性や、複雑な条件分岐を含む検証ロジックには対応できません。

typescript// 実現困難な検証例
// ✅ 単一フィールド: できる
email: z.string().email()

// ❌ フィールド間の関係: できない
// password === confirmPassword のチェック

// ❌ 条件付きバリデーション: できない  
// プランが "premium" の場合のみ必須チェック

複雑な検証ロジックの実装困難

実際のプロジェクトでは、以下のような複雑な検証が求められます:

typescript// 複雑な検証要件の例
interface RegistrationForm {
  email: string;
  password: string;
  confirmPassword: string;
  plan: 'free' | 'premium';
  creditCard?: string; // premium プランの場合のみ必須
  terms: boolean;
}

この例では、プランに応じてクレジットカード情報の必須性が変わるという、条件付きバリデーションが必要です。基本バリデーションだけでは、このような動的な検証ロジックを実装することができません。

エラーハンドリングの複雑さ

複雑なバリデーションでは、エラーの種類や発生箇所を詳細に管理する必要があります。

typescript// 理想的なエラー管理
{
  success: false,
  error: {
    issues: [
      { path: ['password'], message: 'パスワードが弱すぎます' },
      { path: ['confirmPassword'], message: 'パスワードが一致しません' },
      { path: ['creditCard'], message: 'プレミアムプランにはクレジットカードが必要です' }
    ]
  }
}

基本バリデーションでは、このような詳細なエラー制御は困難で、エラーメッセージの柔軟な管理ができません。

解決策

refine の特徴と使用場面

refine は、シンプルなカスタムバリデーションを実装するための機能です。true/false を返すことで検証の成否を判定し、パフォーマンスに優れています。

基本的な構文

refine の基本的な使い方を見てみましょう。

typescript// refine の基本構文
const schema = z.string().refine(
  (value) => value.length > 5, // 検証関数
  { message: "文字列は5文字以上である必要があります" } // エラーメッセージ
);
typescript// オブジェクトスキーマでの使用例
const userSchema = z.object({
  password: z.string(),
  confirmPassword: z.string()
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: "パスワードが一致しません",
    path: ["confirmPassword"] // エラーが発生するフィールドを指定
  }
);

戻り値の処理

refine の検証関数は、以下のいずれかを返すことができます:

typescript// boolean を返すパターン
.refine((data) => data.age >= 18, {
  message: "18歳以上である必要があります"
})
typescript// Promise<boolean> を返すパターン(非同期検証)
.refine(async (email) => {
  const exists = await checkEmailExists(email);
  return !exists; // メールアドレスが存在しない場合のみ通過
}, {
  message: "このメールアドレスは既に使用されています"
})

エラーメッセージのカスタマイズ

refine では、エラーメッセージとパスを柔軟にカスタマイズできます。

typescriptconst registrationSchema = z.object({
  username: z.string(),
  email: z.string().email(),
  password: z.string().min(8)
}).refine(
  (data) => !data.username.includes(data.password),
  {
    message: "パスワードにユーザー名を含めることはできません",
    path: ["password"] // エラーを password フィールドに関連付け
  }
);

superRefine の特徴と使用場面

superRefine は、より高度なカスタムバリデーションが必要な場面で使用します。ctx(context)オブジェクトを通じて、複数のエラーを同時に管理したり、詳細なエラー制御が可能です。

ctx オブジェクトの活用

superRefine の最大の特徴は、ctx オブジェクトを通じてエラーを管理することです。

typescript// superRefine の基本構文
const schema = z.object({
  email: z.string(),
  password: z.string()
}).superRefine((data, ctx) => {
  // エラーを手動で追加
  if (!data.email.includes('@')) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "有効なメールアドレスを入力してください",
      path: ['email']
    });
  }
});
typescript// 複雑な検証ロジックの例
const advancedSchema = z.object({
  startDate: z.date(),
  endDate: z.date(),
  priority: z.enum(['low', 'medium', 'high'])
}).superRefine((data, ctx) => {
  // 複数の検証を同時に実行
  if (data.endDate <= data.startDate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "終了日は開始日より後である必要があります",
      path: ['endDate']
    });
  }
  
  if (data.priority === 'high' && (data.endDate.getTime() - data.startDate.getTime()) > 86400000) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "高優先度のタスクは1日以内に完了する必要があります",
      path: ['priority']
    });
  }
});

複数エラーの管理

superRefine では、一度の検証で複数のエラーを報告できます。

typescriptconst comprehensiveSchema = z.object({
  username: z.string(),
  email: z.string(),
  password: z.string(),
  confirmPassword: z.string()
}).superRefine((data, ctx) => {
  // ユーザー名チェック
  if (data.username.length < 3) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_small,
      minimum: 3,
      type: 'string',
      inclusive: true,
      message: "ユーザー名は3文字以上である必要があります",
      path: ['username']
    });
  }
  
  // パスワード一致チェック
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "パスワードが一致しません",
      path: ['confirmPassword']
    });
  }
  
  // パスワード強度チェック
  const hasNumber = /\d/.test(data.password);
  const hasLetter = /[a-zA-Z]/.test(data.password);
  if (!hasNumber || !hasLetter) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "パスワードには数字と文字の両方を含める必要があります",
      path: ['password']
    });
  }
});

詳細なエラー制御

superRefine では、エラーの種類やメタデータを詳細に設定できます。

typescriptconst businessRuleSchema = z.object({
  orderAmount: z.number(),
  customerType: z.enum(['regular', 'premium', 'vip']),
  discountCode: z.string().optional()
}).superRefine((data, ctx) => {
  // ビジネスルールに基づく複雑な検証
  if (data.customerType === 'regular' && data.orderAmount > 100000) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "一般会員の場合、10万円を超える注文はできません",
      path: ['orderAmount'],
      // カスタムメタデータを追加
      fatal: true, // 致命的エラーとしてマーク
      params: {
        limit: 100000,
        customerType: data.customerType
      }
    });
  }
  
  if (data.discountCode && data.customerType === 'regular') {
    // 警告レベルのエラー
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "一般会員は割引コードを使用できません",
      path: ['discountCode'],
      fatal: false
    });
  }
});

具体例

refine の実装例

シンプルなバリデーション

最もシンプルな refine の使用例から見ていきましょう。

typescript// パスワード強度の基本チェック
const passwordSchema = z.string()
  .min(8, "パスワードは8文字以上である必要があります")
  .refine(
    (password) => /[0-9]/.test(password),
    { message: "パスワードには数字を含める必要があります" }
  )
  .refine(
    (password) => /[a-zA-Z]/.test(password),
    { message: "パスワードには英字を含める必要があります" }
  );

// 使用例
const result = passwordSchema.safeParse("password123");
console.log(result.success); // true

このように、refine を連鎖させることで、複数の条件を順次チェックできます。各 refine は独立して動作し、最初に失敗した時点で検証が停止します。

typescript// メールアドレスのドメイン制限
const companyEmailSchema = z.string()
  .email("有効なメールアドレスを入力してください")
  .refine(
    (email) => email.endsWith('@company.com'),
    { message: "会社のメールアドレスを使用してください" }
  );

条件分岐を含むバリデーション

より複雑な条件分岐を含むバリデーションも refine で実装できます。

typescript// プランに応じた制限チェック
const subscriptionSchema = z.object({
  plan: z.enum(['free', 'premium', 'enterprise']),
  storageUsed: z.number(),
  userCount: z.number()
}).refine(
  (data) => {
    // プランごとの制限をチェック
    const limits = {
      free: { storage: 1000, users: 5 },
      premium: { storage: 10000, users: 50 },
      enterprise: { storage: 100000, users: 500 }
    };
    
    const limit = limits[data.plan];
    return data.storageUsed <= limit.storage && data.userCount <= limit.users;
  },
  {
    message: "選択されたプランの制限を超えています",
    path: ["plan"]
  }
);
typescript// 時間に基づく条件付きバリデーション
const appointmentSchema = z.object({
  datetime: z.date(),
  type: z.enum(['regular', 'emergency'])
}).refine(
  (data) => {
    const hour = data.datetime.getHours();
    // 緊急でない予約は営業時間内のみ
    if (data.type === 'regular') {
      return hour >= 9 && hour <= 17;
    }
    return true; // 緊急の場合は24時間OK
  },
  {
    message: "通常の予約は営業時間内(9:00-17:00)のみ可能です",
    path: ["datetime"]
  }
);

superRefine の実装例

複雑なビジネスロジック

superRefine を使用して、複雑なビジネスロジックを実装してみましょう。

typescript// EC サイトの注文バリデーション
const orderSchema = z.object({
  items: z.array(z.object({
    id: z.string(),
    quantity: z.number(),
    price: z.number()
  })),
  shippingAddress: z.string(),
  billingAddress: z.string(),
  paymentMethod: z.enum(['credit', 'debit', 'paypal']),
  totalAmount: z.number()
}).superRefine((data, ctx) => {
  // 商品の在庫チェック(模擬)
  const unavailableItems = data.items.filter(item => item.quantity > 10);
  unavailableItems.forEach((item, index) => {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: `商品ID: ${item.id} の在庫が不足しています`,
      path: ['items', index, 'quantity']
    });
  });
  
  // 金額の整合性チェック
  const calculatedTotal = data.items.reduce(
    (sum, item) => sum + (item.price * item.quantity), 0
  );
  
  if (Math.abs(calculatedTotal - data.totalAmount) > 0.01) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: `合計金額が正しくありません。計算値: ${calculatedTotal}円`,
      path: ['totalAmount']
    });
  }
  
  // 支払い方法と金額の制限チェック
  if (data.paymentMethod === 'paypal' && data.totalAmount > 50000) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "PayPal決済は5万円以下の注文のみ利用可能です",
      path: ['paymentMethod']
    });
  }
});

このコードでは、在庫チェック、金額整合性、支払い制限という3つの異なる検証を同時に実行し、それぞれ適切なエラーパスとメッセージを設定しています。

複数フィールド間の検証

複数のフィールド間で相互に関連する検証を実装する例です。

typescript// イベント予約システムのバリデーション
const eventBookingSchema = z.object({
  eventType: z.enum(['workshop', 'seminar', 'conference']),
  startTime: z.date(),
  endTime: z.date(),
  attendeeCount: z.number(),
  venue: z.string(),
  cateringRequired: z.boolean()
}).superRefine((data, ctx) => {
  // 時間の整合性チェック
  if (data.endTime <= data.startTime) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "終了時間は開始時間より後である必要があります",
      path: ['endTime']
    });
  }
  
  // イベント種別に応じた制限チェック
  const eventLimits = {
    workshop: { maxAttendees: 20, minDuration: 2 },
    seminar: { maxAttendees: 100, minDuration: 1 },
    conference: { maxAttendees: 500, minDuration: 4 }
  };
  
  const limit = eventLimits[data.eventType];
  const durationHours = (data.endTime.getTime() - data.startTime.getTime()) / (1000 * 60 * 60);
  
  if (data.attendeeCount > limit.maxAttendees) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_big,
      maximum: limit.maxAttendees,
      type: 'number',
      inclusive: true,
      message: `${data.eventType}の最大参加者数は${limit.maxAttendees}人です`,
      path: ['attendeeCount']
    });
  }
  
  if (durationHours < limit.minDuration) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: `${data.eventType}は最低${limit.minDuration}時間必要です`,
      path: ['endTime']
    });
  }
  
  // ケータリングに関する業務ルール
  if (data.cateringRequired && data.attendeeCount < 10) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "ケータリングは10名以上の場合のみ利用可能です",
      path: ['cateringRequired']
    });
  }
});

カスタムエラーパスの設定

superRefine では、エラーパスを動的に設定することも可能です。

typescript// 動的フォームのバリデーション
const dynamicFormSchema = z.object({
  formType: z.enum(['personal', 'business']),
  fields: z.array(z.object({
    name: z.string(),
    value: z.string(),
    required: z.boolean()
  }))
}).superRefine((data, ctx) => {
  data.fields.forEach((field, index) => {
    // 必須フィールドのチェック
    if (field.required && !field.value.trim()) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: `${field.name}は必須項目です`,
        path: ['fields', index, 'value'] // 動的なパス設定
      });
    }
    
    // ビジネスフォーム特有の検証
    if (data.formType === 'business') {
      if (field.name === 'companyCode' && !/^[A-Z]{2}\d{4}$/.test(field.value)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "会社コードは「XX0000」の形式で入力してください",
          path: ['fields', index, 'value']
        });
      }
    }
  });
});

使い分けの判断基準

以下の図は、refinesuperRefine を選択する際の判断フローを示しています。

mermaidflowchart TD
    start[カスタムバリデーションが必要] --> simple{シンプルな<br/>true/false判定?}
    simple -->|Yes| single{単一エラーで<br/>十分?}
    simple -->|No| complex[複雑なロジック]
    
    single -->|Yes| refine[refine を選択]
    single -->|No| multiple[複数エラーが必要]
    
    complex --> control{詳細なエラー<br/>制御が必要?}
    multiple --> superRefine[superRefine を選択]
    control -->|Yes| superRefine
    control -->|No| refine
    
    refine --> performance[パフォーマンス優先]
    superRefine --> flexibility[柔軟性・詳細制御]

図で理解できる要点:

  • シンプルな判定なら refine が適している
  • 複数エラーや詳細制御が必要なら superRefine を選択
  • パフォーマンスと柔軟性のトレードオフを考慮

実際の判断基準を表にまとめると以下のようになります:

#判断ポイントrefinesuperRefine
1検証ロジックの複雑さシンプル複雑
2エラーの数単一複数可
3エラーメッセージの詳細度基本的高詳細
4パフォーマンス高速標準
5実装の簡潔さ簡潔詳細
6動的エラーパス困難容易
typescript// refine が適している場面
const simpleValidation = z.object({
  age: z.number()
}).refine(
  (data) => data.age >= 18,
  { message: "18歳以上である必要があります" }
);

// superRefine が適している場面
const complexValidation = z.object({
  userData: z.object({/* 複雑な構造 */})
}).superRefine((data, ctx) => {
  // 複数の検証ロジック
  // 動的なエラーパス
  // 詳細なエラー情報
});

まとめ

Zod の refinesuperRefine は、どちらもカスタムバリデーションを実現する重要な機能ですが、それぞれに明確な使い分けがあります。

refine を選ぶべき場面:

  • シンプルな true/false 判定で十分
  • パフォーマンスを重視したい
  • 単一のエラーメッセージで十分
  • 実装をシンプルに保ちたい

superRefine を選ぶべき場面:

  • 複数のエラーを同時に報告したい
  • エラーパスを動的に設定したい
  • 詳細なエラー情報が必要
  • 複雑なビジネスロジックを実装したい

適切な選択により、型安全で保守しやすいバリデーションシステムを構築できるでしょう。プロジェクトの要件に応じて、これらのメソッドを使い分けることで、ユーザーにとって親切で開発者にとって管理しやすいバリデーション機能を実現できますね。

関連リンク