react-hook-form と Zod で条件分岐のあるフォームを構築する方法6選

# 概要

React & TypeScript で開発しているプロダクトの場合、フォームライブラリに react-hook-form 、バリデーションライブラリに Zod を選択するシーンが増えているように思います。
https://qiita.com/kjkj_ongr/items/0eff5173b6e4fce7fbe8
https://qiita.com/ka2_75/items/847e59b30495e32c0bf8

フォームの仕様で、「**こっちの項目がこの値の時だけ、あっちの項目を表示する/バリデーションを変える**」といった条件分岐をよく見かけます。

本記事では、 react-hook-form と Zod でバリデーションの条件分岐を実装する方法を紹介します。
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod

## 結論を先に知りたい方へ

react-hook-form と Zod でバリデーションの条件分岐を実装する代表的な方法に、 
- [`.discriminatedUnion`](https://zod.dev/?id=discriminated-unions) を使う方法
- [`.refine`](https://zod.dev/?id=superrefine) を使う方法

があり、 基本的には `.discriminatedUnion` を使って実装するのがおすすめですが、条件分岐を literal で表現しづらい場合は `.refine` を使うことも検討しましょう。
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod

## 執筆時点でのライブラリなどのバージョン

|React| 18.2.0|
|---|---|
|react-hook-form| 7.40.0|
|Zod| 3.19.1|

## `.discriminatedUnion` は v3.12.0 以降

`.discriminatedUnion` は v3.12.0 で 2022/2/25 にリリースされたメソッドです。
https://github.com/colinhacks/zod/releases/tag/v3.12.0
それより前のバージョンで条件分岐を実装するには `.union` や `.superRefine` を使うことになります。

# 条件分岐のサンプル実装

まずは完成形のキャプチャです。
![条件分岐.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435654/a43ced2e-99c7-a9b8-2093-a2ad20218562.gif)
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod

簡素なフォームですが、「犬が好き」にチェックを入れると「好きな犬種」欄が表示される条件分岐が実装されています。

また、バリデーションにも条件分岐があり、「犬が好き」にチェックが入っている場合「好きな犬種」欄は必須項目となり、未入力の場合は「<font color="Red">入力してください</font>」とバリデーションエラーが表示されます。

この時送信ボタンは非活性となっていますが、バリデーションエラーがある状態のままであっても「犬が好き」のチェックが外れると送信ボタンは活性となります。

「犬が好き」のチェックが外れている場合は「好きな犬種」欄は必須項目ではなくなっています。

# サンプルコード

この条件分岐を実装できる Zod のメソッドは6つ見つかりましたが、2つのグループに大別できます。
(他の方法を知っていたら教えて下さい。)

- Union パターンのグループ
  - `.union` 、 `.discriminatedUnion` 、 `.or` のいずれかを使う
  - どのメソッドも判別可能な Union 型になり、型上も条件分岐される
- 変換パターンのグループ
  - `.refine` 、 `.superRefine` 、 `.transform` のいずれかを使う
  - 型は条件分岐されない

## フォームのサンプルコード

フォームのマークアップは下記のようになります。

```tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { InputSchema, schema } from "./schema";

export default function Form() {
  const {
    register,
    watch,
    formState: { errors, isValid },
    handleSubmit,
  } = useForm<InputSchema>({
    defaultValues: { name: "", love_dog: false, type: "" },
    resolver: zodResolver(schema),
    mode: "onChange",
    shouldUnregister: true,
  });

  const love_dog = watch("love_dog");

  const onSubmit = handleSubmit((values) => {
    console.log(values);
  });
  return (
    <form onSubmit={onSubmit}>
      <label>
        名前:
        <br />
        <input {...register("name")} />
      </label>
      <label>
        <input type="checkbox" {...register("love_dog")} />
        犬が好き
      </label>
      {love_dog && (
        <label>
          好きな犬種:
          <br />
          <input {...register("type")} />
          <span>{errors.type?.message}</span>
        </label>
      )}
      <button disabled={!isValid}>送信</button>
    </form>
  );
}
```

以降の説明では、 ./schema をそれぞれのパターンのコードに置き換えるイメージとなります。

## Union パターンの schema のサンプルコード

### `.union`

```ts:schema.ts
import { z } from "zod";

const baseSchema = z.object({
  name: z.string(),
});

const conditionalSchema = z.union([
  z.object({
    love_dog: z.literal(true),
    type: z.string().min(1, "入力してください"),
  }),
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  }),
]);

export const schema = baseSchema.and(conditionalSchema);
export type InputSchema = z.infer<typeof schema>;
```

`.union` の引数は配列になっていて、分岐する条件に合わせて `.object` を定義します。
`love_dog` は「犬が好き」のチェックボックスの値なので通常 boolean 型で定義しますが、 `z.literal(true)` で `true` に限定することができます。
こうすることで、 `love_dog` が `true` の時は `type` が必須項目になり、 `love_dog` が `false` の時は `type` が optional になる条件分岐が作れます。
これはバリデーション上の条件分岐だけでなく、 `z.infer` で得られる型も条件分岐されます。
判別可能な Union 型として扱うことができます。

### `.or`

`.or` は `.union` と機能的には変わりませんが、配列ではなくメソッドチェーンで記述することができます。

```ts
z.object({
  love_dog: z.literal(true),
  type: z.string().min(1, "入力してください"),
}).or(
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  })
);
```

### `.discriminatedUnion`

Zod には `.union` と似た `.discriminatedUnion` というメソッドがあります。

```ts
z.discriminatedUnion("love_dog", [
  z.object({
    love_dog: z.literal(true),
    type: z.string().min(1, "入力してください"),
  }),
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  }),
]);
```

配列で条件分岐が指定できるのは `.union` と変わりませんが、第一引数が文字列になっているのがポイントです。
この文字列には「分岐の条件とする項目の name 値」を設定します。
得られる型も、バリデーションロジックも `.union` の場合と変わりません。
何が変わるかと言うとパフォーマンス(処理速度)です。
`.discriminatedUnion` が導入された pull リクエストにはベンチマークが記載されています。
https://github.com/colinhacks/zod/pull/899

### Union パターンでやりづらいこと

分岐の条件を literal で表現できない場合は、型の旨味を享受することができません。
また、分岐の条件が複数にまたがっている場合、 Union の構造が複雑になったりコードが冗長になるかもしれません。
その場合は、後述の変換パターンで実装するのがよい場面もありそうです。

## 変換パターンの schema のサンプルコード

### `.refine`

`.refine` は対象の schema を再定義するメソッドで、後述の `.superRefine` を手短に書くための糖衣構文です。
第一引数のコールバックの引数に `love_dog` 、 `type` それぞれの値を受け取り、コールバックが `false` を返却する場合は、第二引数の指定にしたがってバリデーションエラーとなります。

```ts
z.object({
  love_dog: z.boolean(),
  type: z.string().optional(),
}).refine(({ love_dog, type }) => !love_dog || (love_dog && !!type), {
  path: ["type"],
  message: "入力してください",
});
```

### `.superRefine`

`.superRefine` は `.refine` と同様に対象の schema を再定義するメソッドです。
`.refine` は一つの項目のバリデーションエラーしか指定できませんが、 `.superRefine` は `.addIssue` を複数回呼び出すことで、複数の項目に渡ってバリデーションエラーを指定することができます。

```ts
z.object({
  love_dog: z.boolean(),
  type: z.string().optional(),
}).superRefine(({ love_dog, type }, ctx) => {
  if (love_dog && !type) {
    ctx.addIssue({
      path: ["type"],
      code: z.ZodIssueCode.too_small,
      minimum: 1,
      type: "string",
      inclusive: false,
      message: "入力してください",
    });
  }
});
```

### `.transform`

`.transform` は `.superRefine` に加えてさらに値の変換ができるメソッドとなっています。

```ts
z.object({
  love_dog: z.boolean(),
  type: z.string().optional(),
}).transform(({ love_dog, type }, ctx) => {
  if (love_dog && !type) {
    ctx.addIssue({
      path: ["type"],
      code: z.ZodIssueCode.custom,
      message: "入力してください",
    });
  }
  return {
    love_dog,
    type,
  };
});
```

### 変換パターンでやりづらいこと

- 型の恩恵が得られない
Union パターンは判別可能な Union 型を得ることができますが、変換パターンはメソッドの呼び出し元の型定義が引き継がれてしまいます。
- Zod のメソッドが使いづらい
`.addIssue` の記述は冗長に感じます。
また、せっかく Zod　を使用しているのに、値の検査に Zod のメソッドが使いづらいです。

## 余談: 他のオブジェクトとの統合には `.and` を使う

オブジェクト schema の結合には `.merge` 、 `.and` 、 `.intersection` があります。
下記の例を見ると、 `baseSchema` はオブジェクト schema ですが、 `conditionalSchema` は Union schema です。
この場合、 `.merge` で統合することはできません。
`.merge` はオブジェクト同士で使用するためです。
他のオブジェクトとの統合には `.and` 、 `.intersection` を使いましょう。

```ts
import { z } from "zod";

const baseSchema = z.object({
  name: z.string(),
});

const conditionalSchema = z.union([
  z.object({
    love_dog: z.literal(true),
    type: z.string().min(1, "入力してください"),
  }),
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  }),
]);

export const schema = baseSchema.and(conditionalSchema);
export type InputSchema = z.infer<typeof schema>;
```

`.intersection` は得られる schema と型は `.and` と同じですが、書き方が異なります。

```ts
export const schema = z.intersection(baseSchema, conditionalSchema);
```

指定する schema は2つに限られるので、3つ以上の schema を統合することはできません。

```
fooSchema
  .and(barSchema)
  .and(hogeSchema)
  .and(piyoSchema)
```

後で3つ目を追加で統合できるように初めから `.and` を使っておくのがよさそうです。

# まとめ

react-hook-form と Zod で条件分岐を実装するのは、いくつかの方法があります。
それぞれの方法にできること/できないこと、やりやすいこと/やりづらいことがあるので適宜ベストなやり方を選択して実装する必要があります。
他にもこのような方法もあるといったコメントをお待ちしております🙇‍♂️