【Zod】union/discriminatedUnionとrefine/superRefineの使い分け

はじめに

この記事の概要

こんにちは、株式会社TOKOSのスギタです!
今回は動的フォーム実装の際に使用するであろうunion/discriminatedUnionとrefine/superRefineの各々の使い分けの判断をまとめてみました!

この記事では、以下の内容について詳しく解説します:

  1. union/discriminatedUnionとrefine/superRefineの基本概念
  2. それぞれの手法が適している具体的なユースケース

Zodを使ったことがある方はもちろん、これから使ってみようと考えている方にとっても、この記事が動的フォーム実装の指針となれば幸いです。ぜひ最後までお付き合いください!

対象読者

  • フロントエンドエンジニアの方
  • Zodを使い始めたばかりの方

この記事で扱う内容、扱わない内容

この記事で扱う内容

  • union/discriminated unionの使い分けの基準
  • refine/superRefineの使い分けの基準

この記事で扱わない内容

  • Zodの基本的な基本的な使用方法
  • React Hook Formの基本的な使用方法

union/discriminatedUnionとrefine/superRefineの使い分け

Zodを使用して動的フォームのバリデーションを行う際、union/discriminatedUnionrefine/superRefineは有用です。
まずはunion/discriminatedUnionrefine/superRefineの各々の使い分けを解説します。

また、TypeScriptのdiscriminated union型を知っているとスムーズに理解できると思いますので、わからない方は下記を先に読んでもらえると良いかと思います!

union/discriminatedUnionの使い分け

unionは、一言で言うと複数の異なるスキーマを1つのスキーマにまとめてくれる機能です。

基本的な構文は下記です。

const stringOrNumberSchema = z.union([z.string(), z.number()]);

上記はstringまたはnumberを許容するスキーマの例です。
このように複数のスキーマを一つのスキーマにまとめてくれます。

では少し複雑にしてみましょう。
この例では、userTypeの値によって動的にスキーマを変更する例です。

const baseUserSchema = z.object({
  isActive: z.boolean(),
});

const studentSchema = baseUserSchema.extend({
  userType: z.literal("student"),
  studentId: z.string(),
  grade: z.number(),
});

const teacherSchema = baseUserSchema.extend({
  userType: z.literal("teacher"),
  teacherId: z.string(),
  subject: z.string(),
});

const userSchema = z.union([studentSchema, teacherSchema]);

type User = z.infer<typeof userSchema>;

userTypeとの関係性は下記になります。

userTypeの値isActivestudentIdteacherIdgradesubject
student
teacher

これによりuserTypeの値が"student""teacher"によって動的にschemaを変えることができます。

ですが上記例のように判別対象(上記であればuserTypeの値)がはっきりしている場合はdiscriminatedUnionを使用しましょう!
下記は上記で定義したスキーマをdiscriminatedUnionで記述したものです。

import { z } from "zod";

const userSchema = z.discriminatedUnion("userType", [
  z.object({
    userType: z.literal("student"),
    isActive: z.boolean(),
    studentId: z.string(),
    grade: z.number(),
  }),
  z.object({
    userType: z.literal("teacher"),
    isActive: z.boolean(),
    teacherId: z.string(),
    subject: z.string(),
  }),
]);

type User = z.infer<typeof UserSchema>;


第一引数に判定対象を指定し、第二引数では判定対象の値ごとのスキーマを配列形式で記述していきます!

上記2つの例のようなスキーマを動的に変更する場合はなるべくdiscriminatedUnionを使用しましょう!
unionからdiscriminatedUnionを使用することによるメリットは下記になります。

型の安全性

discriminatedUnionは、userTypeフィールドが必ず存在し、指定された値のいずれかであることを保証してくれます。
unionでは保証されていない扱いになります。

パフォーマンス

discriminatedUnionは、提供されたオブジェクトが各スキーマと一致するかどうかをチェックする際に 、userTypeフィールドを使用して早期に絞り込みを行うため、パフォーマンスが良いとされています。

エラーメッセージの最適化

unionに比べdiscriminatedUnionを使用した場合エラーメッセージが最適されエラー特定が容易になります。
実例を見たい方は下記を参照してください。

コードの見通しの向上

unionに比べコードの見通しが向上されます。
下記はunionで記述した例です。

判定対象が記述されないため、コードの見通しが悪くなります。

// unionの例
const baseUserSchema = z.object({
  isActive: z.boolean(),
});

const userUnionSchema = z.union([
  baseUserSchema.extend({
    userType: z.literal("student"),
    studentId: z.string(),
    grade: z.number().min(1).max(12),
    major: z.string().optional(),
    gpa: z.number().min(0).max(4).optional(),
  }),
  baseUserSchema.extend({
    userType: z.literal("teacher"),
    teacherId: z.string(),
    subjects: z.array(z.string()).nonempty(),
    yearsOfExperience: z.number().nonnegative(),
    certifications: z.array(z.string()).optional(),
  }),
  baseUserSchema.extend({
    userType: z.literal("admin"),
    adminId: z.string(),
    department: z.string(),
    accessLevel: z.enum(["low", "medium", "high"]),
    canEditUsers: z.boolean(),
  }),

discriminatedUnionに変えた例です。
第一引数で判定対象を明記するので、コードの見通しが良いです。

// unionの例
const baseUserSchema = z.object({
  isActive: z.boolean(),
});

const userDiscriminatedUnionSchema = z.discriminatedUnion("userType", [
  baseUserSchema.extend({
    userType: z.literal("student"),
    studentId: z.string(),
    grade: z.number().min(1).max(12),
    major: z.string().optional(),
    gpa: z.number().min(0).max(4).optional(),
  }),
  baseUserSchema.extend({
    userType: z.literal("teacher"),
    teacherId: z.string(),
    subjects: z.array(z.string()).nonempty(),
    yearsOfExperience: z.number().nonnegative(),
    certifications: z.array(z.string()).optional(),
  }),
  baseUserSchema.extend({
    userType: z.literal("admin"),
    adminId: z.string(),
    department: z.string(),
    accessLevel: z.enum(["low", "medium", "high"]),
    canEditUsers: z.boolean(),
  }),
])

refine/superRefineの使い分け

refine/superRefineは、一言で言うとスキーマに対してバリデーションロジックを追加できるための機能です。

基本的な構文は下記です。

import { z } from "zod";

const evenNumberSchema = z.number()
  .refine((n) => n % 2 === 0, {
    message: "数値は偶数でなければなりません",
  });
type EvenNumber = z.infer<typeof evenNumberSchema>;

上記のようにnumber型で偶数でないといけない時など追加でバリデーションロジックを追加することができます。
またrefineをチェーンすることも可能です。

import { z } from "zod";

const evenNumberSchema = z.number()
  .refine((n) => n % 2 === 0, {
    message: "数値は偶数でなければなりません",
  })
  .refine((n) => n > 10, {
    message: "数値は10より大きくなければなりません",
  });;
type EvenNumber = z.infer<typeof evenNumberSchema>;

Parse時にrefineで記述したカスタムバリデーションの判定が実行されます。

またお互いの値が依存している場合のバリデーションロジックを記述する際に適しています。
下記はパスワードと確認用のパスワードが一致しているかのバリデーションロジックの例です。

import { z } from "zod";

const passwordSchema = z.object({
  password: z.string(),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードと確認用パスワードが一致しません",
  path: ["confirmPassword"]
});

type PasswordInput = z.infer<typeof PasswordSchema>;

このように2つの値が依存関係である場合にとても有用です。
refine内の第二引数にはエラーメッセージを記述することができます。
上記例では、エラーメッセージを「パスワードと確認用パスワードが一致しません」でエラーフィールドを「confirmPassword」にしています。


更に複雑なバリデーションロジックを実装したい場合にsuperRefineは有用になります。
具体的には依存する値が複数の場合や複雑なバリデーションロジックを追加する場合です。

下記はユーザー登録フォームのスキーマです。

import { z } from "zod";

const USER_ROLES = ["user", "admin", "superadmin"] as const;

const formSchema = z.object({
  name: z.string().min(3).max(20),  // 名前
  email: z.string().email(),        // メールアドレス
  age: z.number().int().positive(), // 年齢
  role: z.enum(USER_ROLES),         // 役割
  password: z.string().min(8),      // パスワード
  confirmPassword: z.string(),      // 確認用パスワード
  agreeToTerms: z.boolean(),        // 利用規約に同意
})

ここから条件を追加します。

条件は下記です。

  • パスワードと確認用パスワードの一致判定
    • passwordとconfirmPasswordが一致しているか
  • 年齢と役割の関連判定
    • roleがadminはageが25際以上である必要がある
  • スーパー管理者の登録条件判定
    • superadminは30歳以上である必要がある
    • emailの@以降がcompany.comになっているか
  • 利用規約の同意判定
    • agreenToTermsに値があるか
  • パスワードのバリデーション判定
    • 指定のフォーマットに沿っているか

では先ほど定義したスキーマにバリデーションロジックを追加します。

import { z } from "zod";

const USER_ROLES = ["user", "admin", "superadmin"] as const;

const formSchema = z.object({
  name: z.string().min(3).max(20),  // 名前
  email: z.string().email(),        // メールアドレス
  age: z.number().int().positive(), // 年齢
  role: z.enum(USER_ROLES),         // 役割
  password: z.string().min(8),      // パスワード
  confirmPassword: z.string(),      // 確認用パスワード
  agreeToTerms: z.boolean(),        // 利用規約に同意
})
.superRefine((data, ctx) => {
  // パスワードと確認用パスワードの一致判定
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "パスワードと確認用パスワードが一致しません",
      path: ["confirmPassword"],
    });
  }

  // 年齢と役割の関連判定
  if (data.role === "admin" && data.age < 25) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "管理者は25歳以上である必要があります",
      path: ["age"],
    });
  }

  // スーパー管理者の登録条件判定
  if (data.role === "superadmin") {
    if (data.age < 30) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "スーパー管理者は30歳以上である必要があります",
        path: ["age"],
      });
    }
    if (!data.email.endsWith("@company.com")) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "スーパー管理者は会社のメールアドレスを使用する必要があります",
        path: ["email"],
      });
    }
  }

  // 利用規約同意のチェック
  if (!data.agreeToTerms) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "利用規約に同意する必要があります",
      path: ["agreeToTerms"],
    });
  }

  // パスワードのバリデーション判定
  const passwordStrengthRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
  if (!passwordStrengthRegex.test(data.password)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "パスワードは少なくとも1つの大文字、小文字、数字、特殊文字を含む必要があります",
      path: ["password"],
    });
  }
});

type Form = z.infer<typeof formSchema>;

細かいバリデーションロジックの解説はしませんが、複数の値が依存関係である場合や値の結果によってバリデーションを変更したい場合にsuperRefineが適しています。
refineでできないことやコードが複雑になる場合はsuperRefineを使用しましょう!

さいごに

今回はZodのunion/discriminatedUnionとrefine/superRefineの各々の使い分けに関して解説いたしました!
いつ使えばいいんだとなかなか分かりづらいところだと思うので解説してみました!
Zodでも使用頻度が高いと思いますので適切に使い分けれるようになりたいところですね。