react-hook-formでyupとzodの違いを検証
始めに
react-hook-form
では、バリデーションロジックをresolver
オプションのところを変更することでyup
やzod
などのバリデーションライブラリを使うことができます。
const { control, handleSubmit } = useForm({
resolver: /* yupResolverやzodResolverを入れる */,
})
最近はよくzod
を見かけるのですが、僕はyup
しか使ったことがなかっため、どういった違いがあるのか、またzod
の方が本当に良いのかというところが気になったのでサンプルコードを書いて使い勝手を検証しました。
検証内容
今回は以下のようなフォームに対してのバリデーション設定をしました。
- メールアドレス(メールアドレス形式バリデーション+必須)
- ナマエ(カタカナバリデーション、文字数制限)
- パスワード(必須)
- 確認パスワード(パスワードと一致しているかのクロスフィールドバリデーション)
これらのバリデーションを設定するに当たって、以下の点を重点的に見ました。
- エラーメッセージの設定
- 共通の日本語エラーメッセージの設定がしやすいか
- カスタムルールの指定のしやすさ
- カタカナルールなど、独自のバリデーションルールを定義した際の呼び出しやすいか
- クロスフィールドバリデーションのやりやすさ
- パスワードが一致しているかなど、クロスフィールドの値を参照してバリデーションすることがやりやすいか
- 必須、文字数制限などのパラメータをスキーマから参照しやすいか
- スキーマとUIでパラメータをそれぞれ指定するとパラメータずれを起こすため、スキーマで設定した値を取得してUI側に設定することが容易か
- TypeScriptとの相性(推論、型設定のやりやすさ)
- スキーマで定義したものから型を算出したり、逆に事前に型を定義したものからスキーマを定義できるか
検証コード
検証したコードはCodeSandboxで書きましたので以下に貼っておきます。詳細のコードや動きを見たい方はご参照ください。
結論
先に結論だけ書くと、yupの方が圧倒的に使いやすく、柔軟性があるなと思いました。zodの方が軽量で型がより堅牢ではありますが、個人的にはバリデーションは設定のしやすさに重きを置くべきだと思っており、zodでもやれなくはないですが大分やりづらさを感じました。
各観点の比較
エラーメッセージの設定
yupは各スキーマのルールをkeyにエラーメッセージを指定できるため、一括で対応づけた設定が容易です。また各フィールドにラベルを設定することもできるので、1箇所に全てのエラーメッセージを出す際には役立ちます(フィールドごとにエラーメッセージを出す場合は必要のない機能だと思いますが)。
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
が直接紐づいていないことが多く、メッセージの設定に結構手間取ります。
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()
という感じで他のルールと同じように設定できるようになって非常に使い勝手が良いです。
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;
}
}
import * as yup from "yup";
const katanakaSchema = yup.string().katakana();
型も追加定義しているため、キチンと型推論もしてくれます😄
一方zodは新しくルールとして定義することはできません。しかし、superRefine
で独自のバリデーションメソッドを渡すことはできるため、そのメソッドを生成するutilityを用意したら使い回すことができそうです。
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
});
}
};
};
import { z } from "zod";
import { kanaRule } from "./kanaRule";
const katakanaSchema = z.string().superRefine(kanaRule());
ちなみに.refine
もカスタムバリデーションとして定義できますが、こちらはメソッドはエラーメッセージが第2引数に書く必要があり、共通コードとして切り出しづらくなるので.superRefine
を選択しました。
こんな感じでzodもカスタムルールの使い回しはできないことはありませんが、yupの方が圧倒的に直感的に書けるなと思いました。
クロスフィールドバリデーションのやりやすさ
yupはクロスフィールドの参照もやりやすく、yup.ref('フィールドパス')
で指定したフィールド値を参照してバリデーションしてくれます。また、testメソッドでcontext.parentで親のフィールドにアクセスできるので、そこから参照して独自バリデーションを書くこともできます。ただし、どちらの書き方も型は当たらないので注意が必要です。他のフィールドの状態が分からなくても書けてしまっているので型の当てようがないのは当然と言えば当然で、型よりも指定しやすさを優先した結果なのかなと個人的には思っています。
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する際にはどこのフィールドにエラーを出すかを指定する必要が出てきます。ちなみにこのフィールド指定には型が効いていなかったので存在しないフィールドを指定しないように注意する必要があります(型を意識するならここも頑張ってほしかったですね)。基本的にエラーメッセージを表示するフィールドを起点に他のフィールドを参照してバリデーションを書いた方がどういうエラーメッセージが出てくるかも見やすいと思うので、この書き方はあんまり直感的ではないなと個人的には感じました。
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は参照できます。
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を外す作業が必要になります。
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を定義することができます。
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はスキーマから型を算出することも、逆に先に定義した型をベースにスキーマを定義することができます。
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を見つけてようやく分かりました。
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>
これだけだとyupもzodも大差ないですが、zodの方がenumをサポートしていたり、intersecton schemaがあったりと少し優勢です。
総評
以上の観点での比較から、yupの方が圧倒的にバリデーションの設定はしやすいなと思いました。ただyupの方は無理やり型を拡張させたり、anyの状態でクロスフィールド参照したり、割と型を無視してでも使い勝手を優先したような印象を感じました。一方でzodは厳格さを重視した結果周りくどい設定方法になってしまっているのかなと思いました。
バリデーションにおいてはyupの方が痒いところまで手が届くのでこちらの方が良いと思いましたが、zodは色々なエコシステムがあるようで、tRPCとの連携やモックデータの生成など、サードパーティとの連携も考慮するとこちらの方が軍杯が上がるかもと思いました。
終わりに
以上がreact-hook-formにおけるyupとzodの使い勝手の違いでした。yupの方が色々できることが多いので個人的には圧倒的にyupの方が良いと思いましたが、zodのエコシステムの多さには驚き、エコシステムを利用するためにzodを使うことを検討しても良いかなと思いました。今回の検証でどちらも実装不可能というケースは存在せず、単に書きやすいか否かだけだったので、zodを選択しても詰むことはなさそうでした。ただ複雑なことをしようとすると大分zodだと苦しくなりそうな印象でしたが。。
yupが良いかzodが良いかで悩んでいる方の参考になれば幸いです。
Discussion