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

2025/01/30に公開

はじめに

1ヶ月後にモンスターハンターになる、ノベルワークスのりょうちん(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

plan.ts
const planSchema = z.object({
    type: z.literal("basic"),
    currentCredit: NonNegativeIntSchema
      .lte(100, "Monthly limit exceeded because of basic plan"),
  })

.refineを使用した場合

.refineは引数に(バリデート関数, パラメータ)を取るもので、バリデート関数がfalseの場合にパラメータを返すというエラーハンドリングが柔軟に実装できるのが特徴です。
https://zod.dev/?id=refine

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をキーにして実装したと仮定します。

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の概念に揉まれて苦労したものの、拡張性があり便利だなと感じています。
まだまだ触り始めのため、もっと開拓してガッチガチのコーディングが出来るようになりたいです。
皆さんの役に立てば嬉しいです👾

参考

Discussion