概要
React & TypeScript で開発しているプロダクトの場合、フォームライブラリに react-hook-form 、バリデーションライブラリに Zod を選択するシーンが増えているように思います。
https://qiita.com/kjkj_ongr/items/0eff5173b6e4fce7fbe8
https://qiita.com/ka2_75/items/847e59b30495e32c0bf8
フォームの仕様で、「こっちの項目がこの値の時だけ、あっちの項目を表示する/バリデーションを変える」といった条件分岐をよく見かけます。
本記事では、 react-hook-form と Zod でバリデーションの条件分岐を実装する方法を紹介します。
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod
結論を先に知りたい方へ
react-hook-form と Zod でバリデーションの条件分岐を実装する代表的な方法に、
-
.discriminatedUnion
を使う方法 -
.refine
を使う方法
があり、 基本的には .discriminatedUnion
を使って実装するのがおすすめですが、条件分岐を literal で表現しづらい場合は .refine
を使うことも検討しましょう。
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod
執筆時点でのライブラリなどのバージョン
React | 18.2.0 |
---|---|
react-hook-form | 7.40.0 |
Zod | 3.19.1 |
.discriminatedUnion
は v3.12.0 以降
.discriminatedUnion
は v3.12.0 で 2022/2/25 にリリースされたメソッドです。
https://github.com/colinhacks/zod/releases/tag/v3.12.0
それより前のバージョンで条件分岐を実装するには .union
や .superRefine
を使うことになります。
条件分岐のサンプル実装
まずは完成形のキャプチャです。
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod
簡素なフォームですが、「犬が好き」にチェックを入れると「好きな犬種」欄が表示される条件分岐が実装されています。
また、バリデーションにも条件分岐があり、「犬が好き」にチェックが入っている場合「好きな犬種」欄は必須項目となり、未入力の場合は「入力してください」とバリデーションエラーが表示されます。
この時送信ボタンは非活性となっていますが、バリデーションエラーがある状態のままであっても「犬が好き」のチェックが外れると送信ボタンは活性となります。
「犬が好き」のチェックが外れている場合は「好きな犬種」欄は必須項目ではなくなっています。
サンプルコード
この条件分岐を実装できる Zod のメソッドは6つ見つかりましたが、2つのグループに大別できます。
(他の方法を知っていたら教えて下さい。)
- Union パターンのグループ
-
.union
、.discriminatedUnion
、.or
のいずれかを使う - どのメソッドも判別可能な Union 型になり、型上も条件分岐される
-
- 変換パターンのグループ
-
.refine
、.superRefine
、.transform
のいずれかを使う - 型は条件分岐されない
-
フォームのサンプルコード
フォームのマークアップは下記のようになります。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { InputSchema, schema } from "./schema";
export default function Form() {
const {
register,
watch,
formState: { errors, isValid },
handleSubmit,
} = useForm<InputSchema>({
defaultValues: { name: "", love_dog: false, type: "" },
resolver: zodResolver(schema),
mode: "onChange",
shouldUnregister: true,
});
const love_dog = watch("love_dog");
const onSubmit = handleSubmit((values) => {
console.log(values);
});
return (
<form onSubmit={onSubmit}>
<label>
名前:
<br />
<input {...register("name")} />
</label>
<label>
<input type="checkbox" {...register("love_dog")} />
犬が好き
</label>
{love_dog && (
<label>
好きな犬種:
<br />
<input {...register("type")} />
<span>{errors.type?.message}</span>
</label>
)}
<button disabled={!isValid}>送信</button>
</form>
);
}
以降の説明では、 ./schema をそれぞれのパターンのコードに置き換えるイメージとなります。
Union パターンの schema のサンプルコード
.union
import { z } from "zod";
const baseSchema = z.object({
name: z.string(),
});
const conditionalSchema = z.union([
z.object({
love_dog: z.literal(true),
type: z.string().min(1, "入力してください"),
}),
z.object({
love_dog: z.literal(false),
type: z.string().optional(),
}),
]);
export const schema = baseSchema.and(conditionalSchema);
export type InputSchema = z.infer<typeof schema>;
.union
の引数は配列になっていて、分岐する条件に合わせて .object
を定義します。
love_dog
は「犬が好き」のチェックボックスの値なので通常 boolean 型で定義しますが、 z.literal(true)
で true
に限定することができます。
こうすることで、 love_dog
が true
の時は type
が必須項目になり、 love_dog
が false
の時は type
が optional になる条件分岐が作れます。
これはバリデーション上の条件分岐だけでなく、 z.infer
で得られる型も条件分岐されます。
判別可能な Union 型として扱うことができます。
.or
.or
は .union
と機能的には変わりませんが、配列ではなくメソッドチェーンで記述することができます。
z.object({
love_dog: z.literal(true),
type: z.string().min(1, "入力してください"),
}).or(
z.object({
love_dog: z.literal(false),
type: z.string().optional(),
})
);
.discriminatedUnion
Zod には .union
と似た .discriminatedUnion
というメソッドがあります。
z.discriminatedUnion("love_dog", [
z.object({
love_dog: z.literal(true),
type: z.string().min(1, "入力してください"),
}),
z.object({
love_dog: z.literal(false),
type: z.string().optional(),
}),
]);
配列で条件分岐が指定できるのは .union
と変わりませんが、第一引数が文字列になっているのがポイントです。
この文字列には「分岐の条件とする項目の name 値」を設定します。
得られる型も、バリデーションロジックも .union
の場合と変わりません。
何が変わるかと言うとパフォーマンス(処理速度)です。
.discriminatedUnion
が導入された pull リクエストにはベンチマークが記載されています。
https://github.com/colinhacks/zod/pull/899
Union パターンでやりづらいこと
分岐の条件を literal で表現できない場合は、型の旨味を享受することができません。
また、分岐の条件が複数にまたがっている場合、 Union の構造が複雑になったりコードが冗長になるかもしれません。
その場合は、後述の変換パターンで実装するのがよい場面もありそうです。
変換パターンの schema のサンプルコード
.refine
.refine
は対象の schema を再定義するメソッドで、後述の .superRefine
を手短に書くための糖衣構文です。
第一引数のコールバックの引数に love_dog
、 type
それぞれの値を受け取り、コールバックが false
を返却する場合は、第二引数の指定にしたがってバリデーションエラーとなります。
z.object({
love_dog: z.boolean(),
type: z.string().optional(),
}).refine(({ love_dog, type }) => !love_dog || (love_dog && !!type), {
path: ["type"],
message: "入力してください",
});
.superRefine
.superRefine
は .refine
と同様に対象の schema を再定義するメソッドです。
.refine
は一つの項目のバリデーションエラーしか指定できませんが、 .superRefine
は .addIssue
を複数回呼び出すことで、複数の項目に渡ってバリデーションエラーを指定することができます。
z.object({
love_dog: z.boolean(),
type: z.string().optional(),
}).superRefine(({ love_dog, type }, ctx) => {
if (love_dog && !type) {
ctx.addIssue({
path: ["type"],
code: z.ZodIssueCode.too_small,
minimum: 1,
type: "string",
inclusive: false,
message: "入力してください",
});
}
});
.transform
.transform
は .superRefine
に加えてさらに値の変換ができるメソッドとなっています。
z.object({
love_dog: z.boolean(),
type: z.string().optional(),
}).transform(({ love_dog, type }, ctx) => {
if (love_dog && !type) {
ctx.addIssue({
path: ["type"],
code: z.ZodIssueCode.custom,
message: "入力してください",
});
}
return {
love_dog,
type,
};
});
変換パターンでやりづらいこと
- 型の恩恵が得られない
Union パターンは判別可能な Union 型を得ることができますが、変換パターンはメソッドの呼び出し元の型定義が引き継がれてしまいます。 - Zod のメソッドが使いづらい
.addIssue
の記述は冗長に感じます。
また、せっかく Zod を使用しているのに、値の検査に Zod のメソッドが使いづらいです。
余談: 他のオブジェクトとの統合には .and
を使う
オブジェクト schema の結合には .merge
、 .and
、 .intersection
があります。
下記の例を見ると、 baseSchema
はオブジェクト schema ですが、 conditionalSchema
は Union schema です。
この場合、 .merge
で統合することはできません。
.merge
はオブジェクト同士で使用するためです。
他のオブジェクトとの統合には .and
、 .intersection
を使いましょう。
import { z } from "zod";
const baseSchema = z.object({
name: z.string(),
});
const conditionalSchema = z.union([
z.object({
love_dog: z.literal(true),
type: z.string().min(1, "入力してください"),
}),
z.object({
love_dog: z.literal(false),
type: z.string().optional(),
}),
]);
export const schema = baseSchema.and(conditionalSchema);
export type InputSchema = z.infer<typeof schema>;
.intersection
は得られる schema と型は .and
と同じですが、書き方が異なります。
export const schema = z.intersection(baseSchema, conditionalSchema);
指定する schema は2つに限られるので、3つ以上の schema を統合することはできません。
fooSchema
.and(barSchema)
.and(hogeSchema)
.and(piyoSchema)
後で3つ目を追加で統合できるように初めから .and
を使っておくのがよさそうです。
まとめ
react-hook-form と Zod で条件分岐を実装するのは、いくつかの方法があります。
それぞれの方法にできること/できないこと、やりやすいこと/やりづらいことがあるので適宜ベストなやり方を選択して実装する必要があります。
他にもこのような方法もあるといったコメントをお待ちしております🙇♂️