zodを用いた条件分岐バリデーションを実装してみる

# はじめに
1ヶ月後にモンスターハンターになる、ノベルワークスのりょうちん（[ryotech34](https://x.com/ryotech34)）です。

今回は、TypeScript向けに設計されたスキーマ宣言やバリデーションを助けてくれるライブラリである、zodを使用して条件分岐バリデーションを実装した際の備忘録です。

https://zod.dev/

ドメインモデルとzodを組み合わせた実装方法として、下記の記事を参考にさせていただきました。ぜひ確認してみてください。

https://zenn.dev/kimutyam/articles/zod-domain-model

# 対象読者
- zodに触れているもしくはこれから触ろうと思っている方

# 話さないこと
- zodの基本的な記法

# 本題
## テーマ
toB向けのサービスがあり、組織単位でベーシック、プロ、エンタープライズという3つのプランで契約できるとします。
そこでは、プランごとに毎月使用できるクレジットが制限されており、クレジットが制限値を超えると当該月では使用できないようにするという想定です。

## やりたいこと
今回はプランが変更された場合、プランのタイプに合わせてスキーマまるごと変更を行い、不整合があれば型にしたがってエラーを吐かせたいというものです。
変更箇所は、プランを示す`type`と現在のクレジットを示す`currentCredit`の2つです。
この`currentCredit`の上限値を、条件分岐で変更したいというのが今回の主旨になります。

## zodで上限値を設定する
zodで上限値を設定するには、主に`.lte`と`.refine`が使えます。

### `.lte`を使用した場合
`.max`は引数に（上限値, メッセージ）を取るもので、シンプルに実装できます。

https://zod.dev/?id=numbers

```ts: plan.ts
const planSchema = z.object({
    type: z.literal("basic"),
    currentCredit: NonNegativeIntSchema
      .lte(100, "Monthly limit exceeded because of basic plan"),
  })
```
:::note info
`z.number()`において`.gte`は`.min`で、`.lte`は`.max`でも実装できる。
```ts:number.ts
z.number().gt(5);
z.number().gte(5); // alias .min(5)
z.number().lt(5);
z.number().lte(5); // alias .max(5)
```
:::
### `.refine`を使用した場合
`.refine`は引数に（バリデート関数, パラメータ）を取るもので、バリデート関数が`false`の場合にパラメータを返すというエラーハンドリングが柔軟に実装できるのが特徴です。

https://zod.dev/?id=refine

```ts: plan.ts
const planSchema = z.object({
    type: z.literal("basic"),
    currentCredit: NonNegativeIntSchema
      .refine((value) => value <= 100, {
        message: "Monthly limit exceeded because of basic plan",  
      }),
  })
```

今回は、
- 複雑なエラーハンドリングが出来る
- `.lte`は記事としてはシンプルすぎる

以上のことから`.refine`を採用したと仮定して続きます。

### `.superRefine`
`.refine`よりも複雑なエラーハンドリングができる`.superRefine`というものもあります。`.refine`を含め、以下の記事にわかりやすくまとめられていたので確認してみてください。

https://zenn.dev/s_takashi/articles/99e18f74995a87

## zodでスキーマを条件分岐で定義する
条件分岐で定義する方法として、`.discriminatedUnion`があります。
引数に（キー、条件分岐リスト）をすることで、リスト内のキーを参照して条件分岐を実装することができます。

https://zod.dev/?id=discriminated-unions

今回は`type`をキーにして実装したと仮定します。
```ts: plan.ts
export const PlanSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("basic"),
    currentCredit: NonNegativeIntSchema
      .refine((value) => value <= 100, {
        message: "Monthly limit exceeded because of basic plan",  
      }),
  }),
  z.object({
    type: z.literal("pro"),
    currentCredit: NonNegativeIntSchema
      .refine((value) => value <= 200, {
        message: "Monthly limit exceeded because of pro plan",  
      }), 
  }),
  z.object({
    type: z.literal("enterprise"),
    currentCredit: NonNegativeIntSchema
      .refine((value) => value <= 300, { 
        message: "Monthly limit exceeded because of enterprise plan",
      }),
  }),
]);
```
`currentCredit`が月上限値を超えた場合、エラーを吐くようになります。

- `currentCredit`がそれぞれのプランで上限値をもつ
- 型チェックをzodを使用した関数で統一できる
- コードがシンプルになる

ようになり、型安全性とメンテナンス性が向上したと思います。

# 最後に
今回はzodを活用して、条件分岐で変わるバリデーションをスキーマに移してみました。最初こそ新しいzodの概念に揉まれて苦労したものの、拡張性があり便利だなと感じています。
まだまだ触り始めのため、もっと開拓してガッチガチのコーディングが出来るようになりたいです。
皆さんの役に立てば嬉しいです👾

# 参考
- [Zod Documentation](https://zod.dev/)
- [ZodでAlways-Valid Domain Modelを実現する](https://zenn.dev/kimutyam/articles/zod-domain-model)
- [zodで相関チェックをする（refine）](https://zenn.dev/s_takashi/articles/99e18f74995a87)