⚠️ 記事の内容が古くなっているのでご注意ください。
v7.44.0 より、TTransformedValues を設定できるようになったため、本記事で紹介しているユーティリティーは非公開としました。
はじめに
こんにちは! テクノロジー戦略本部 開発二部の金子です。
自分が担当するプロジェクトでは、React Hook Form (以後RHFとする) と Zod を用いてフォームの実装をしています。
この組み合わせは強力ですが、Zod スキーマから生成される型を useForm の型引数へ設定する際に、様々な型不整合が起きました。
本記事では、上記を解消するためのユーティリティーを公開したので、紹介します。
対象読者
以下のような課題を抱えている開発者の参考になれば幸いです。
- 型引数の設定が難しく、チームで用いるには学習コストが高い
- Zod の
transform
を使うと型が不一致になる
RHF + Zod を用いる動機
RHF + Zod を用いる動機について、登録フォームを例に挙げて説明します。
フォームの初期化からAPIリクエストまでの流れは、以下のようになります。
- デフォルト値を与えることでフォームを初期化
- フォームに値を入力
- 送信ボタンをクリック
- バリデーションチェックを行い、正当性を確認
- 無効な値の場合は 「2.」 に戻り、有効な場合は後続処理に進む
- フォームの値を登録APIのリクエストボディの形式に整形
- APIを叩く
「4.」 と 「6.」 を Zod が担うことで、以下の2点の恩恵が得られます。
- 「4. バリデーションチェック」 を Zod のスキーマで定義できるようになり、フォームとバリデーションロジックを分離できる
- 「6. APIのリクエストボディの形式に整形」 を Zod の
transform
を用いることで 整形処理を Zod に委譲できる
以上より、我々のチームでは RHF に zod を取り入れています。 以前の記事でも動機を説明していたので、詳しくは以下の記事を参照してください。
RHF + Zod の課題
RHF + Zod の組み合わせは非常に強力ですが、実際に利用してみたところ以下の2点の課題が見受けられました。
- 型引数の設定が難しく、チームで用いるには学習コストが高い
- Zod の
transform
を使うと型が不一致になる
型引数の設定が難しく、チームで用いるには学習コストが高い
RHF + Zod における、型引数の煩雑さについて説明します。 フォームの状態遷移は、以下のようになります。
まず、「フォームのデフォルト値」は、値が入っているかは不確定の型となります。
次に、バリデーションチェックを行うことで、narrowing
された型になります。
最後に、transform
により、リクエストボディの型に変換されます。
上記の型は、RHF と Zod のユーティリティー型を用いて表現できます。
具体的には、RHF の DeepPartial
と Zod の z.input
, z.output
を用います。
DeepPartial
は、再帰的に値が入っているか不確定な型を表現できます。
z.input
と z.output
は、Zod スキーマの型を型引数に渡すことで、バリデーション後の型と transform
後の型を表現できます。
以上を踏まえて、実際のコードで比較します。 例として、姓と名を入力するフォームを用います。
- Zodを用いない場合
type User = { firstName?: string; lastName?: string; }; useForm<User>();
- Zodを用いる場合
const schema = z .object({ firstName: z.string().min(1), lastName: z.string().min(1), }); // ❌ 型引数の設定が煩雑 useForm<DeepPartial<z.input<typeof schema>>>();
両者を比較すると、Zod を用いて型引数を設定する方法は、煩雑であることがわかります。
Zod の transform
を使うと型が不一致になる
Zod の transform
を利用した際の型不整合について説明します。
例として、姓と名を transform
により結合する場合を考えます。
const schema = z .object({ firstName: z.string().min(1), lastName: z.string().min(1), }) .transform((val) => ({ fullName: `${val.firstName} ${val.lastName}`, }));
バリデーション後の型と整形後の型は、z.input
と z.output
を用いると、以下のようになります。
type input = z.input<typeof schema> // { firstName: string; lastName: string; } type output = z.output<typeof schema> // { fullName: string; }
このスキーマを RHF に適応すると以下のようになります。
const { handleSubmit } = useForm<DeepPartial<z.input<typeof schema>>>({ resolver: zodResolver(customerSchema), }); const onSubmit = handleSubmit((data) => { // ❌ data の実態と Data の型が一致しない type Data = typeof data // { firstName: string; lastName: string; } console.log(data); // { fullName: "..." } });
data
の実態は z.output<typeof schema>
になるため、 fullName
のみを持っています。
しかし、Data
の型は z.input<typeof schema>
が適応されており、 { firstName: string; lastName: string; }
となってしまいます。
以上より、transform
を用いると型の不整合が起こります。
本件については、RHF に Issue が発行されており、v8で修正予定となっています。
提案手法
以上の問題点を解消するため、弊社では独自のユーティリティーを自作しました。それぞれの問題をどのように改善したか説明していきます。
型引数の煩雑さを解消する方法
- Zod のスキーマを引数とし、部分適用された
useForm
を返すユーティリティー (createZodForm
) を作成 - ユーティリティー内で
resolver
と型パラメータを設定し、部分適応したuseForm
を返す
コードで表現すると、以下のようになります。
const createZodForm = <T extends z.Schema<any, any>>(schema: T) => <TContext = any>( props?: Omit<UseFormProps<DeepPartial<z.input<T>>, TContext>, "resolver"> ) => { const resolver = zodResolver(schema, undefined, { rawValues: true, }); return useForm({ resolver, ...props }); };
本メソッドにより、Zod のスキーマを渡すだけで、型パラメータと resolver
が部分適用された useForm
を作成できます。
const useForm = createZodForm(schema)
transform
の型不整合を解消する方法
zodResolver
のrawValues
オプションを有効にするhandleSubmit
をラップした関数 (handleParsed
) を作成し、transform
処理を行う
コードを抜粋すると、以下のような実装になっています。
zodResolver(schema, undefined, { rawValues: true });
const handleParsed = (onValid, onInvalid) => { const handleValid = (data, error) => { const parsedData = schema.parse(data); return onValid(parsedData, error); }; return methods.handleSubmit(handleValid, onInvalid); };
rawValues
オプションを有効にすると、transform
を無効化できるため、型不整合を解消できます。
handleSubmit
をラップした関数 (handleParsed
) は、onValid
の data
に Zod の parse
メソッドを実行した結果を渡しています。これにより、transform
後の値を引数に持つ handleSubmit
を実現しています。
ユーティリティーの使用例
例として、姓と名を入力するフォームを用います。
import { createZodForm } from '@buysell-technologies/react-hook-form-zod'; // ✅ 型引数の設定が不要 // createZodForm を用いると、型引数が設定された useForm を取得できる const useForm = createZodForm(schema); const { handleParsed } = useForm(); const onSubmit = handleParsed((data) => { // ✅ data の実態と Data の型が一致している type Data = typeof data // { fullName: "..." } console.log(data); // { fullName: "..." } });
createZodForm
を import
し、schema
を引数に渡すことで型引数が設定された useForm
を取得できます。
型引数を自身で行う場合と比較すると、schema
を渡すことだけで設定できるため、簡単に設定できることがわかります。
整形後の型不整合に関しては、handleParsed
という関数を提供することで解決しています。
handleSubmit
を利用した場合と比較すると、型不整合が解消されていることがわかります。
Q & A
Q1. Zod の deepPartial
使わない理由はありますか?
Zod の deepPartial
を使うと、バリデーションチェック後の型が DeepPartial
型になるためです。
期待値は、デフォルト値が DeepPartial
型で、バリデーションチェック後の方は DeepPartial
が外れた型となります。
Q2. useZodForm の返り値として、 handleSubmit
も提供しているのは何故ですか?
handleSubmit
を提供しない場合、既存の useForm
の返り値の型と互換性が取れなくなります。
そのため、返り値をそのまま利用するインタフェースで実装している箇所で後方互換性が取れなくなります。
例えば、useForm
の返り値を FormProvider
に対して、スプレッド演算子で展開しながら渡す場合などが挙げられます。
const useForm = createZodForm(schema) const methods = useForm() // ❌ handleSubmit をオーバーライドすると型不整合が起きる <FormProvider {...methods} />
まとめ
RHF + Zod における型不整合を解消するためのユーティリティーを作りました。 複雑な型引数の設定をユーティリティーで隠蔽しているため、初学者でも簡単に利用できるようになりました。
このパッケージが同様の悩みを抱えている方の助けになればと思っております。
BuySell Technologiesではエンジニアを募集しています。こういった取組に興味がある方はぜひご応募をお願いします!