Closed5

大きくなりがちなZodスキーマの対処法メモ

さんやまさんやま

以下は、React Hook Form + Zod を使ったフォームバリデーションで、条件によって表示される項目が変わりスキーマ定義が肥大化しがちな場合の対処法をまとめたメモ。

さんやまさんやま

スキーマを分割してモジュール化する

大きくなったスキーマは、機能ごとにスキーマを分割することで見通しをよくする。
メインのスキーマファイルで、分割したスキーマを合成する。
例:下記のようにparticipationSchema.ts, addressSchema.ts のようにドメインごとにファイルをわける

// participationSchema.ts
import { z } from "zod";

export const participationSchema = z.object({
  participation: z.enum(["participate", "notParticipate"]),
  place: z.string().optional(), // 参加時のみ必須にしたい場合は別途 refine などで対応
});

// addressSchema.ts
import { z } from "zod";

export const addressSchema = z.object({
  postalCode: z.string(),
  prefecture: z.string(),
  city: z.string(),
});

// mainSchema.ts
import { z } from "zod";
import { participationSchema } from "./participationSchema";
import { addressSchema } from "./addressSchema";

export const mainSchema = z
  .object({
    // 他にも必要なスキーマがあればここで
  })
  .merge(participationSchema)
  .merge(addressSchema);
// z.object({ ...participationSchema.shape, ...addressSchema.shape }) みたいな感じでmergeも可能らしい
さんやまさんやま

z.discriminatedUnionやz.union を活用する

import { z } from "zod";

const participationUnionSchema = z.discriminatedUnion("participation", [
  z.object({
    participation: z.literal("participate"),
    place: z.string().min(1, "参加場所は必須です"),
    // 参加する場合のみ必要な項目
  }),
  z.object({
    participation: z.literal("notParticipate"),
    // 不参加の場合は項目なし or 別途何かがあればここに追加
  }),
]);

export type ParticipationFormType = z.infer<typeof participationUnionSchema>;

React Hook Form と組み合わせる際は、useForm の resolver にこのスキーマをそのまま渡して使用

さんやまさんやま

不要なフィールドはoptionalにして、refineで条件にしたバリデーションを実装

  • 状況によって表示されないフィールドをoptionalとして定義し、Zod の refine や superRefine で「条件が合致する場合は必須にする」などのロジックを追加
  • 条件分岐の数が多いとスキーマが煩雑になるので、細分化して対処できるなら discriminatedUnion や複数スキーマの合成を優先する
// baseSchema.ts
import { z } from "zod";

export const baseSchema = z
  .object({
    participation: z.enum(["participate", "notParticipate"]),
    place: z.string().optional(),
  })
  .refine(
    (data) => {
      if (data.participation === "participate") {
        return !!data.place; // place が必須
      }
      return true;
    },
    {
      message: "参加場所は必須です",
      path: ["place"],
    }
  );
このスクラップは2025/01/19にクローズされました