React Hook Form × Zod のバリデーションを試行錯誤した話

## はじめに
こんにちは。GxPの上野です。
この記事は[グロースエクスパートナーズAdvent Calendar 2024](https://qiita.com/advent-calendar/2024/gxp)の16日目です。

配属3か月目の新入社員がReact Hook Form × Zodでのバリデーションを行うために、約3日半試行錯誤した記録です。

一度、React Hook Formを利用してバリデーションを実装した上で、Zodを利用したバリデーションの検証を行いました。
今回は、解決しづらい問題が発生したため、Zodを利用したバリデーションを断念して、React Hook Formを利用したバリデーションを採用することになりました。

検証中にどんな問題が発生したのか、Zodを採用するうえで何が妨げになったのかをまとめてみました。

<details><summary>React Hook FormとZodのとても軽い説明</summary>

**[React Hook Form](https://www.react-hook-form.com/)**
ReactでFormの処理を効率的に管理するためのライブラリ

**[Zod](https://zod.dev/)**
TypeScriptファーストのスキーマ定義とバリデーションのライブラリ

</details>

:::note warn
試行錯誤の結果、Zodでのバリデーションを採用しないという結論に至ったため、解決していない問題があります。
:::


### 案件のざっくりとした概要
約70個の入力項目のある書類を管理することができるWebアプリを作成しています。
（この入力項目の多さが後々影響を与えてきます...）
書類の作成、修正、詳細表示を行えることが大まかな要件です。
すでにDBのテーブルは存在していて、ほかのアプリからのデータが入っている、という状態です。

### バリデーションの要件
今回実装したいバリデーションルールは以下の2つ
- 入力値の必須チェック
- 入力値の数値チェック

また、選択する項目において「その他」を選択した場合は、その他に対する具体的な入力を必須としたい、という要件もありました。

例：
Q：好きな果物は？
りんご・みかん・ぶどう・その他
A: その他：**いちご**　←ここを必須としたい
    

## 環境
- React : 19.0.0-rc-206df66e-20240912
- Next : 15.0.0-canary.56
- MUI : 6.1.1
- React Hook form : 7.53.1
- Zod : 3.23.8
- zodResolver : 3.9.1
- PostgreSQL

## 奮闘記録
### Zodに出会うまで
React Hook Formを利用して、以下のようにバリデーションの設定を行っていました。
同じバリデーションルールを複数ヵ所で利用したいため、関数に切り出して使っています。
``` tsx
<Controller
    render={({ field }) => (
        <TextField
            {...field}
            label="title"
            variant={"outlined"}
            sx={{ width: 1 }}
            error={!!errors.title}
            helperText={errors.title?.message}
        />
    )}
    control={control}
    name={"title"}
    rules={{ validate: requiredRule }} // ここでバリデーションルールを設定
/>
// バリデーションルールを2つ以上使用する場合
<Controller
    render={({ field }) => (
        <TextField
            {...field}
            label="age"
            error={!!errors.user.age}
            helperText={errors.user.age?.message}
        />
    )}
    control={control}
    name={"user.age"}
    // ここでバリデーションルールを設定
    rules={{
        validate: (value) =>
            validateRules(value, [requiredRule, onlyNumberRule]),
    }}
/>
```
切り出した関数については、
引数に入力値をとり、bool値または文字列(エラーで表示したい文言)を返すように作成しました。

``` tsx
export type ValidateRuleType = (
  value: string | number | null | undefined,
) => string | boolean;

// 必須チェック
export const requiredRule: ValidateRuleType = (value) => {
  if (!value && value !== 0) {
    return "必須です"
  }
  return true;
};

// 数値チェック
export const onlyNumberRule: ValidateRuleType = (value) => {
  if (value === null || Number.isNaN(Number(value?.toString()))) {
    return "数値で入力してください";
  }
  return true;
};
```
入力必須かつ数値のみという場合もあるので、１つの入力に対して複数のバリデーションルールを渡すことができるように、バリデーションルールの配列を受け取り、実行する関数も合わせて用意しました。
``` tsx
export function validateRules(
  value: string | number | null | undefined,
  rules: ValidateRuleType[],
): string | boolean {

  let error: string | boolean = true;
  
  rules.map((rule) => {
    const result = rule(value);
    if (typeof result === "string") {
      error = result;
    }
  });
  return error;
}
```
### Zodとの出会い
きっかけはコードレビューをしてくださっている先輩からの指摘でした。
> validationについてuseFormのresolverとzodライブラリを利用することでrule.tsなどを定義しなくてもformの型定義と同時に設定できるかもしれません。一旦こちらを試してみてもらえないでしょうか

Zodって何？というところから調べ始め、ここから3日半に及ぶZodとの戦いが始まりました。

### Zodとの戦い
Zodとの戦いの中でも手ごわかったものについてまとめていこうと思います。
Zodを採用しないことに決まったため、未解決の問題も多いです。
また、詳しく理解できていないところも多いため解説は少なめです…

#### Lv1. 数値の選択によって必須をつけ外しする
React Hook Formでのバリデーションは入力欄がレンダリングされた状態でのみ実行されるため、選択によって必要なくなる入力欄については、入力欄の表示を制御することで入力を求める場合のみ、バリデーションを実行していました。
Zodでは入力欄の有無にかかわらず、オブジェクト内に定義した項目についてバリデーションが行われるため、[discriminatedUnion](https://zod.dev/?id=discriminated-unions)を利用して、条件によりオブジェクトの指定を分岐を行いました。

``` ts
z.discriminatedUnion(
  "selectValue",
  [
    z.object({
      selectValue: z.enum(["0", "1", "2", "3", "4"]),
      other: z.string().optional(),
    }),
    z.object({
      selectValue: z.literal("5"),
      other: z.string().min(1),
    }),
  ],
);
```
selectのvalueに渡している値がstringの値であれば、上記のように[enum](https://zod.dev/?id=zod-enums)での定義が可能ですが、今回はvalueにnumberの値を渡していたため、この定義方法ではどの値を選択してもinvalid_union_discriminatorとなってしまいました。
enumでは数値を持つことはできず、[refine](https://zod.dev/?id=refine)を使ってみたりもしましたがうまく動かず、最終的な形は以下の通りになりました。
``` ts
z.discriminatedUnion(
  "selectValue",
  [
    z.object({
      selectValue: z.literal(0),
      other: z.string().optional(),
    }),
    z.object({
      selectValue: z.literal(1),
      other: z.string().optional(),
    }),
    z.object({
      selectValue: z.literal(2),
      other: z.string().optional(),
    }),
    z.object({
      selectValue: z.literal(3),
      other: z.string().optional(),
    }),
    z.object({
      selectValue: z.literal(4),
      other: z.string().optional(),
    }),
    z.object({
      selectValue: z.literal(5),
      other: z.string().min(1),
    }),
  ],
);
```
enumを使ったときのように、もっとシンプルにまとめて書けないものかと考えましたが、今の私の知識ではこれが限界でした。

#### Lv2. 必須かつ数字のみの制限をかける

:::note warn
未解決問題です
:::
文字列に対しては、**min(1)** の指定を追加することによって、文字数が1文字未満の場合にtoo_smallのエラーを出すことで、必須チェックをすることができます。

問題となったのは**0も許す数値を必須にする**ことです。
MUIのTextFieldで受け取る入力値はstringのため、[coerce](https://zod.dev/?id=coercion-for-primitives)でnumberに変換した上でmin(1)を指定してみました。
``` ts 
inputNum : z.coerce.number().min(1)
```
上記指定では1以上の数値のみの必須入力とするため、0を入力したときにtoo_smallのエラーが出てしまいます。
そもそも、z.coerceで空文字を数値に変換したときの結果が0であるため、入力値が0の時と入力がない時の判断を付けるには、coerceではない方法で数値に変換する必要があるようです。

#### Lv3. DBから受け取ったデータをzodで定義した型に渡す
zodのオブジェクトから型を定義できることから、DBから取ってきたデータも同じ型に入れたいと考えました。
ということで、DBから取ってきたデータの型を変換するコンバーターにZodのオブジェクトから定義した型を適用してみました。
ここで発生した問題は大きく2つです。

**1. DBではNullが許容されていた**
Zodのオブジェクトはnullやundefinedを許容していないため、当然のごとく生成した型にNullは入りません。
Zodから生成される型のためにNullableを追加するのは本末転倒であり、この問題がZodを使わないことにした理由の一つです。

**2. Lv1で定義した選択肢の型がユニオン型になってしまう**
Lv1で定義した選択肢について、生成した型は以下のようになります。
``` ts
selectValue : 0 | 1 | 2 | 3 | 4 | 5;
```
DBでの定義にユニオン型は存在せず、integerになってしまうため、0~5であることを確認を行わないと値を入れることができませんでした。

#### Lv4. いつのまにかエラーが乱発

:::note warn
未解決問題です
:::

入力を受け取るオブジェクトの項目数が50件を超えたころ、エディタ内で以下のエラーが複数ヵ所に発生しました。
``` 
Type instantiation is excessively deep and possibly infinite.ts(2589)
```
実行時にはエラーは発生せず、動作へ目に見える影響はありませんでしたが、最後までこのエラーを解決することはできませんでした。
こちらのエラーを解消できなかったことも、Zodを使わないことにした理由の1つになりました。

## まとめ
今回、Zodでのバリデーションは断念することになった主な理由は、DBのテーブル定義と、入力時のバリデーションの定義がそろっていなかったこと、Zodを利用して定義するには、入力項目が膨大で複雑すぎたこと、そして乱発したエラーについては解決策の糸口すら見つかっていない状態であることでした。
コード的には、3日半のZodとの戦いはなかったことになりましたが、Zodを使ってみる経験や、調べて動かして確かめる経験はとてもよかったと感じました。

ここまで読んでいただきありがとうございました。
同じような問題に直面している人の手助けになればうれしいです！