🤖

react-hook-formでyupとzodの違いを検証

2023/10/01に公開

始めに

react-hook-formでは、バリデーションロジックをresolverオプションのところを変更することでyupzodなどのバリデーションライブラリを使うことができます。

const { control, handleSubmit } = useForm({
  resolver: /* yupResolverやzodResolverを入れる */,
})

最近はよくzodを見かけるのですが、僕はyupしか使ったことがなかっため、どういった違いがあるのか、またzodの方が本当に良いのかというところが気になったのでサンプルコードを書いて使い勝手を検証しました。

検証内容

今回は以下のようなフォームに対してのバリデーション設定をしました。

  • メールアドレス(メールアドレス形式バリデーション+必須)
  • ナマエ(カタカナバリデーション、文字数制限)
  • パスワード(必須)
  • 確認パスワード(パスワードと一致しているかのクロスフィールドバリデーション)

これらのバリデーションを設定するに当たって、以下の点を重点的に見ました。

  • エラーメッセージの設定
    • 共通の日本語エラーメッセージの設定がしやすいか
  • カスタムルールの指定のしやすさ
    • カタカナルールなど、独自のバリデーションルールを定義した際の呼び出しやすいか
  • クロスフィールドバリデーションのやりやすさ
    • パスワードが一致しているかなど、クロスフィールドの値を参照してバリデーションすることがやりやすいか
  • 必須、文字数制限などのパラメータをスキーマから参照しやすいか
    • スキーマとUIでパラメータをそれぞれ指定するとパラメータずれを起こすため、スキーマで設定した値を取得してUI側に設定することが容易か
  • TypeScriptとの相性(推論、型設定のやりやすさ)
    • スキーマで定義したものから型を算出したり、逆に事前に型を定義したものからスキーマを定義できるか

検証コード

検証したコードはCodeSandboxで書きましたので以下に貼っておきます。詳細のコードや動きを見たい方はご参照ください。

結論

先に結論だけ書くと、yupの方が圧倒的に使いやすく、柔軟性があるなと思いました。zodの方が軽量で型がより堅牢ではありますが、個人的にはバリデーションは設定のしやすさに重きを置くべきだと思っており、zodでもやれなくはないですが大分やりづらさを感じました。

各観点の比較

エラーメッセージの設定

yupは各スキーマのルールをkeyにエラーメッセージを指定できるため、一括で対応づけた設定が容易です。また各フィールドにラベルを設定することもできるので、1箇所に全てのエラーメッセージを出す際には役立ちます(フィールドごとにエラーメッセージを出す場合は必要のない機能だと思いますが)。

yupの共通エラーメッセージ設定
import { LocaleObject, setLocale } from "yup";

const locale: LocaleObject = {
  mixed: {
    required: ({ label }) => (label ? label + "は必須です" : "入力必須です")
  },
  string: {
    max: ({ label, max }) =>
      (label ? label + "は" : "") + `${max}文字以下で入力してください`,
    email: ({ label }) =>
      (label ? label + "は" : "") + "メールアドレスの形式が合っていません"
  }
};

setLocale(locale);

一方、zodはissue.codeを見てメッセージを返すメソッドを定義します。issue.codeとtypeを見るため少し条件分岐が込み入ってしまうのが少し気になりましたが、一番致命的だったのはルールとissue.codeが一対一で紐づいていないことです。stringの場合は.nonempty()で一文字以上入力するというバリデーションが設定されますが、それはどうやら.min(1)のエイリアスのようで、そのパラメータを見て必須ルールと判断する必要があります。このようにルールとissue.codeが直接紐づいていないことが多く、メッセージの設定に結構手間取ります。

zodの共通エラーメッセージ設定
import { z } from "zod";

export const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
    case z.ZodIssueCode.too_small:
      if (issue.type === "string" && issue.minimum === 1) {
        return {
          message: "入力必須です"
        };
      }
      break;
    case z.ZodIssueCode.too_big:
      if (issue.type === "string") {
        return {
          message: `${issue.maximum}文字以下で入力してください`
        };
      }
      break;
    case z.ZodIssueCode.invalid_string:
      if (issue.validation === "email") {
        return {
          message: "メールアドレスの形式が合っていません"
        };
      }
  }
  return {
    message: ctx.defaultError
  };
};

z.setErrorMap(customErrorMap);

カスタムルールの指定のしやすさ

yupはaddMethodでカスタムルールを新しく追加することができるため、以下のように書くとyup.string().katakana()という感じで他のルールと同じように設定できるようになって非常に使い勝手が良いです。

yupでカスタムルールを定義する
import { addMethod, Message, string, StringSchema } from "yup";

const katakanaMessage: Message = ({ label }) =>
  (label ? label + "は" : "") + "カタカナで入力してください";

export const isKatakana = (kana: string) => {
  return /^([-]||)*$/.test(kana || "");
};

addMethod<StringSchema>(string, "katakana", function (
  message: Message = katakanaMessage
) {
  return this.test("katakana", function (value, testContext) {
    if (isKatakana(value || "")) {
      return true;
    }

    return testContext.createError({
      message
    });
  });
});

declare module "yup" {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  interface StringSchema<TType, TContext, TDefault, TFlags> {
    katakana(): this;
  }
}
yupのカスタムルールの呼び出し
import * as yup from "yup";

const katanakaSchema = yup.string().katakana();

型も追加定義しているため、キチンと型推論もしてくれます😄

一方zodは新しくルールとして定義することはできません。しかし、superRefineで独自のバリデーションメソッドを渡すことはできるため、そのメソッドを生成するutilityを用意したら使い回すことができそうです。

zodでカスタムルールを定義する
import { z } from "zod";

/** superrefineに渡すバリデーションメソッド */
type Refinement<Value> = (value: Value, ctx: z.RefinementCtx) => void;

const ERROR_MESSAGE_KANA = "カタカナで入力してください";

/**
 * カタカナバリデーションルール
 */
export const kanaRule = ({
  message = ERROR_MESSAGE_KANA
}: {
  message?: string;
} = {}): Refinement<string> => {
  return (value, ctx) => {
    if (!/^([-]||)*$/.test(value)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message
      });
    }
  };
};
zodのカスタムルールの呼び出し
import { z } from "zod";
import { kanaRule } from "./kanaRule";

const katakanaSchema = z.string().superRefine(kanaRule());

ちなみに.refineもカスタムバリデーションとして定義できますが、こちらはメソッドはエラーメッセージが第2引数に書く必要があり、共通コードとして切り出しづらくなるので.superRefineを選択しました。

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

こんな感じでzodもカスタムルールの使い回しはできないことはありませんが、yupの方が圧倒的に直感的に書けるなと思いました。

クロスフィールドバリデーションのやりやすさ

yupはクロスフィールドの参照もやりやすく、yup.ref('フィールドパス')で指定したフィールド値を参照してバリデーションしてくれます。また、testメソッドでcontext.parentで親のフィールドにアクセスできるので、そこから参照して独自バリデーションを書くこともできます。ただし、どちらの書き方も型は当たらないので注意が必要です。他のフィールドの状態が分からなくても書けてしまっているので型の当てようがないのは当然と言えば当然で、型よりも指定しやすさを優先した結果なのかなと個人的には思っています。

yupでクロスフィールドバリデーションをする
export const yupFormSchema = yup.object({
  password: yup.string().required(),
  password2: yup
    .string()
    .required()
    .oneOf([yup.ref("password")], "パスワードが一致しません")
    // あるいは以下のように独自バリデーションを書く
    //.test((value, ctx) => {
    //  const { password } = ctx.parent;
    //  if (value === password) {
    //    return true;
    //  }
    //  return ctx.createError({
    //    message: "パスワードが一致しません"
    //  });
    //})
});

zodは型を優先したためか、他のフィールドを参照することができません。代わりに親の方(オブジェクトデータ)からsuperRefineでバリデーションすることでオブジェクトに含まれている各プロパティを参照しながらバリデーションを書くことができます。ただしバリデーションを実行しているパスが親に移動しているため、addIssueする際にはどこのフィールドにエラーを出すかを指定する必要が出てきます。ちなみにこのフィールド指定には型が効いていなかったので存在しないフィールドを指定しないように注意する必要があります(型を意識するならここも頑張ってほしかったですね)。基本的にエラーメッセージを表示するフィールドを起点に他のフィールドを参照してバリデーションを書いた方がどういうエラーメッセージが出てくるかも見やすいと思うので、この書き方はあんまり直感的ではないなと個人的には感じました。

zodでクロスフィールドバリデーションする
export const zodFormSchema = z
  .object({
    password: z.string().nonempty(),
    password2: z.string().nonempty()
  })
  .superRefine((value, ctx) => {
    if (value.password !== value.password2) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "パスワードが一致しません",
	// どのフィールドにエラーメッセージを出すか指定する
        path: ["password2"]
      });
    }
  });

必須、文字数制限などのパラメータをスキーマから参照しやすいか

yupにはschema.describe()でバリデーションするルール名やパラメータを取得することができるため、そこからパラメータを取得することができます。またObjectSchemaの場合はobjSchema.fieldsで各フィールドのschemaを参照することができるので、formSchemaから該当するフィールドのmaxLengthは参照できます。

yupでmaxパラメータを取得する
import * as yup from "yup";

/**
 * スキーマからmaxLength値を取得する
 * @param schema - スキーマ
 */
export const getMaxLengthBySchema = (
  schema: yup.ISchema<string> | ReturnType<typeof yup.ref>
): number | undefined => {
  if (schema instanceof yup.string) {
    const { tests } = schema.describe();
    for (const test of tests) {
      if (test.name === "max") {
        return test.params?.max as number | undefined;
      }
    }
  }
  return undefined;
};

// maxLengthを取得する例
const yupFormSchema = yup.object({
  nameKana: yup.string().defined().katakana().max(10),
});
const maxNameKanaLength = getMaxLengthBySchema(yupFormSchema.fields.nameKana)

zodの場合はz.ZodStringでmaxLengthを持っているため、その値さえ参照すれば良いのですが、formSchemaから参照しようと思うと中々大変です。クロスフィールドやカスタムバリデーションを設定するために.superRefineを実行しているため、型がz.ZodEffectsになっている可能性があるためです。幸いZodEffectsは既存のスキーマをラップしているだけで元のスキーマも._def.schemaにあるので丁寧に紐解けば参照することは可能ですが、前準備でZodEffectsを外す作業が必要になります。

ZodEffectsのラップを外す
import { z } from "zod";

export type WrappedEffectsSchema<Schema extends z.ZodType> =
  | z.ZodEffects<WrappedEffectsSchema<Schema>, any, any>
  | Schema;

/**
 * ZodEffectsでラップされているスキーマを外す
 * @param schema - zodスキーマ
 */
export const unwrapEffectsSchema = <Schema extends z.ZodType>(
  schema: WrappedEffectsSchema<Schema>
): Schema => {
  if (schema instanceof z.ZodEffects) {
    return unwrapEffectsSchema(schema._def.schema);
  }
  return schema;
};

これで指定したフィールドのmaxLengthを参照するutilityを定義することができます。

zodでmaxLengthパラメータを取得する
import { z } from "zod";
import {
  unwrapEffectsSchema,
  WrappedEffectsSchema
} from "./unwrapEffectsSchema";

/**
 * オブジェクトスキーマにある特定のパスのmaxLength値を取得する
 * @param schema - オブジェクトスキーマ
 * @param path - 参照したいパス
 */
export const pickMaxLength = <T extends Record<string, z.ZodType>>(
  schema: WrappedEffectsSchema<z.ZodObject<T, any, any>>,
  path: keyof T
): number | undefined => {
  const objSchema = unwrapEffectsSchema(schema);
  const fieldSchema = unwrapEffectsSchema(objSchema.shape[path]);
  if (fieldSchema instanceof z.ZodString) {
    return fieldSchema.maxLength ?? undefined;
  }
  return undefined;
};

// maxLengthを取得する例
const zodFormSchema = z
  .object({
    nameKana: z.string().max(10).superRefine(kanaRule()),
  });
const maxNameKanaLength = pickMaxLength(zodFormSchema, "nameKana");

TypeScriptとの相性(推論、型設定のやりやすさ)

yupはスキーマから型を算出することも、逆に先に定義した型をベースにスキーマを定義することができます。

yupスキーマからの型算出、型に合わせたyupスキーマ定義
import * as yup from "~/yup";

export const yupFormSchema = yup.object({
  email: yup.string().required().email(),
  nameKana: yup.string().defined().katakana().max(10),
  password: yup.string().password().required(),
  password2: yup
    .string()
    .password()
    .required()
    .oneOf([yup.ref("password")], "パスワードが一致しません")
});

export type YupForm = yup.Asserts<typeof yupFormSchema>;

// 型を先に指定することもできる
const yupFormSchema: yup.ObjectSchema<{
  email: string;
  nameKana: string;
  password: string;
  password2: string;
}> = yup.object({
  // 省略
});

zodもスキーマから型を算出することも、逆に既存の型に合わせたスキーマを定義することも可能でした。ただ後者の方は中々方法が見つからず、こちらのIssueを見つけてようやく分かりました。

zodスキーマから型算出、型に合わせたzodスキーマ定義
import { z, kanaRule, passwordRule } from "~/zod";

export const zodFormSchema = z
  .object({
    email: z.string().nonempty().email(),
    nameKana: z.string().max(10).superRefine(kanaRule()),
    password: z.string().nonempty().superRefine(passwordRule()),
    password2: z.string().nonempty().superRefine(passwordRule())
  })
  .superRefine((value, ctx) => {
    if (value.password !== value.password2) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "パスワードが一致しません",
        path: ["password2"]
      });
    }
  });
  
export type ZodForm = z.infer<typeof zodFormSchema>;

// satisfiesで期待する型に合わせたスキーマ定義が可能
const zodFormSchema = z
  .object({
    // 省略
  })
  .superRefine((value, ctx) => {
    // 省略
  }) satisfies z.ZodType<{
    email: string;
    nameKana: string;
    password: string;
    password2: string;
  }, any, any>

https://github.com/colinhacks/zod/issues/372#issuecomment-1432110698

これだけだとyupもzodも大差ないですが、zodの方がenumをサポートしていたり、intersecton schemaがあったりと少し優勢です。

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

総評

以上の観点での比較から、yupの方が圧倒的にバリデーションの設定はしやすいなと思いました。ただyupの方は無理やり型を拡張させたり、anyの状態でクロスフィールド参照したり、割と型を無視してでも使い勝手を優先したような印象を感じました。一方でzodは厳格さを重視した結果周りくどい設定方法になってしまっているのかなと思いました。
バリデーションにおいてはyupの方が痒いところまで手が届くのでこちらの方が良いと思いましたが、zodは色々なエコシステムがあるようで、tRPCとの連携やモックデータの生成など、サードパーティとの連携も考慮するとこちらの方が軍杯が上がるかもと思いました。

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

終わりに

以上がreact-hook-formにおけるyupとzodの使い勝手の違いでした。yupの方が色々できることが多いので個人的には圧倒的にyupの方が良いと思いましたが、zodのエコシステムの多さには驚き、エコシステムを利用するためにzodを使うことを検討しても良いかなと思いました。今回の検証でどちらも実装不可能というケースは存在せず、単に書きやすいか否かだけだったので、zodを選択しても詰むことはなさそうでした。ただ複雑なことをしようとすると大分zodだと苦しくなりそうな印象でしたが。。
yupが良いかzodが良いかで悩んでいる方の参考になれば幸いです。

Discussion