logo

discriminatedUnionでスキーマを細かく制御する

2025-03-05
30 days ago

開発環境

  • next 15.2.0
  • react 19.0.0
  • react-hook-form 7.54.2
  • @hookform/resolvers 4.1.1
  • zod 3.24.2

前提

フォーム画面はReactHookFormZodを利用した実装になります。

紹介するソースコードはそのままでは動作しません。(あくまで雰囲気を掴んでいただくためのものです)

本題

様々なフォーム画面を実装してきましたが、ネストするフォームもたまにありますよね。

そんな時、バリデーションの制御に苦労したことはありませんか?

例えば、(1)を選択したときは(A)は必須、(B)は任意、そもそも(1)が選択されていない時は(A)(B)には何もバリデーションかけない・・・のように、細かい制御が求められることがあります。

そんな時におすすめなのがdiscriminatedUnionです。

discriminatedUnionとは?

ZoddiscriminatedUnionは、オブジェクトの特定のフィールド(識別フィールド)の値に応じて異なるスキーマを適用できる機能です。

複数のバリエーションがあるデータ構造を安全にバリデーションするのに便利です。

ポイントは以下です。

① 第一引数に「識別フィールド」を指定

  • 例: z.discriminatedUnion("type", [...]) → type の値で分岐する

② 各スキーマで「識別フィールドの値」が異なる

  • "A" の場合 → valueA が必要
  • "B" の場合 → valueB が必要

③ 識別フィールドの値が一致するスキーマが自動適用される

  • 不要なフィールドを含んでいるとエラーになる

実装例

今回は「イベント(eventCategory)」という3種類の値を持つ選択肢を識別フィールドにし、その値に連動する項目がいくつかある例を紹介します。

RadioGroupFieldに関しては説明を割愛します。すべての項目が「複数選択肢のラジオボタン」だと認識していただければ大丈夫です。


コードはこちら

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

enum EventType {
  REMOTE = 'REMOTE',
  ONSITE = 'ONSITE',
  HYBRID = 'HYBRID',
}

const schema = z.object({
  meeting: z.discriminatedUnion('eventCategory', [
    z.object({
      eventCategory: z.literal(EventType.REMOTE),
      remoteOption1: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      remoteOption2: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption1: z.enum(['A', 'B', 'C', 'D']).nullish(),
      onsiteOption2: z.enum(['A', 'B', 'C', 'D']).nullish(),
      onsiteOption3: z.enum(['A', 'B', 'C', 'D']).nullish(),
    }),
    z.object({
      eventCategory: z.literal(EventType.ONSITE),
      onsiteOption1: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption2: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption3: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      remoteOption1: z.enum(['A', 'B', 'C', 'D']).nullish(),
      remoteOption2: z.enum(['A', 'B', 'C', 'D']).nullish(),
    }),
    z.object({
      eventCategory: z.literal(EventType.HYBRID),
      onsiteOption1: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption2: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption3: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      remoteOption1: z.enum(['A', 'B', 'C', 'D']).nullish(),
      remoteOption2: z.enum(['A', 'B', 'C', 'D']).nullish(),
    }),
  ]),
});

export const DiscriminatedUnionForm = () => {
  const { register, handleSubmit } = useForm({
    resolver: zodResolver(schema),
  });

  const onSubmit = async () => {
    // async request which may result error
    try {
      // await fetch()
    } catch (e) {
      // handle your error
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <RadioGroupField
        legend="eventCategoryの質問文"
        {...register('meeting.eventCategory')}
      />
      <RadioGroupField
        legend="onsiteOption1の質問文"
        {...register('meeting.onsiteOption1')}
      />
      <RadioGroupField
        legend="onsiteOption2の質問文"
        {...register('meeting.onsiteOption2')}
      />
      <RadioGroupField
        legend="onsiteOption3の質問文"
        {...register('meeting.onsiteOption3')}
      />
      <RadioGroupField
        legend="remoteOption1の質問文"
        {...register('meeting.remoteOption1')}
      />
      <RadioGroupField
        legend="remoteOption2の質問文"
        {...register('meeting.remoteOption2')}
      />
      <button type="submit">保存</button>
    </form>
  );
};

eventCategoryが未選択の時のエラーメッセージをカスタマイズする

上記のスキーマの定義だとeventCategoryが未選択の時、デフォルトのエラーメッセージ(英語)が出力されます。

なので、以下のようにエラーマップを定義してメッセージをカスタマイズします。

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

enum EventType {
  REMOTE = 'REMOTE',
  ONSITE = 'ONSITE',
  HYBRID = 'HYBRID',
}

+ const customErrorMap: z.ZodErrorMap = (error, ctx) => {
+   /*
+   This is where you override the various error codes
+   */
+   switch (error.code) {
+     case z.ZodIssueCode.invalid_union_discriminator:
+       return { message: '必須項目です' };
+   }
+
+   // fall back to default message!
+   return { message: ctx.defaultError };
+ };

const schema = z.object({
  meeting: z.discriminatedUnion('eventCategory', [
    z.object({
      eventCategory: z.literal(EventType.REMOTE),
      remoteOption1: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      remoteOption2: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption1: z.enum(['A', 'B', 'C', 'D']).nullish(),
      onsiteOption2: z.enum(['A', 'B', 'C', 'D']).nullish(),
      onsiteOption3: z.enum(['A', 'B', 'C', 'D']).nullish(),
    }),
    z.object({
      eventCategory: z.literal(EventType.ONSITE),
      onsiteOption1: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption2: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption3: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      remoteOption1: z.enum(['A', 'B', 'C', 'D']).nullish(),
      remoteOption2: z.enum(['A', 'B', 'C', 'D']).nullish(),
    }),
    z.object({
      eventCategory: z.literal(EventType.HYBRID),
      onsiteOption1: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption2: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      onsiteOption3: z.enum(['A', 'B', 'C', 'D'], { message: '必須項目です' }),
      remoteOption1: z.enum(['A', 'B', 'C', 'D']).nullish(),
      remoteOption2: z.enum(['A', 'B', 'C', 'D']).nullish(),
    }),
- ]),
+ ],
+ { errorMap: customErrorMap },
+ ),
});

// ...省略

};

これでeventCategoryが未選択の時は「必須項目です」のメッセージを出力することができました。

さいごに

zodにはrefinesuperRefineのようなAPIもあります。

これらはパース後に実行されるので、その点理解した上で、やりたいことが実現できるのであればこれらを選択肢しても良いでしょう。

参照