バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

React Hook Form + Zodにおける型不整合の解消

⚠️ 記事の内容が古くなっているのでご注意ください。
v7.44.0 より、TTransformedValues を設定できるようになったため、本記事で紹介しているユーティリティーは非公開としました。

はじめに

こんにちは! テクノロジー戦略本部 開発二部の金子です。

自分が担当するプロジェクトでは、React Hook Form (以後RHFとする)Zod を用いてフォームの実装をしています。

この組み合わせは強力ですが、Zod スキーマから生成される型を useForm の型引数へ設定する際に、様々な型不整合が起きました。

本記事では、上記を解消するためのユーティリティーを公開したので、紹介します。

github.com

対象読者

以下のような課題を抱えている開発者の参考になれば幸いです。

  • 型引数の設定が難しく、チームで用いるには学習コストが高い
  • Zodtransform を使うと型が不一致になる

RHF + Zod を用いる動機

RHF + Zod を用いる動機について、登録フォームを例に挙げて説明します。

名称未設定ファイル-ページ2のコピー.drawio.png

フォームの初期化からAPIリクエストまでの流れは、以下のようになります。

  1. デフォルト値を与えることでフォームを初期化
  2. フォームに値を入力
  3. 送信ボタンをクリック
  4. バリデーションチェックを行い、正当性を確認
  5. 無効な値の場合は 「2.」 に戻り、有効な場合は後続処理に進む
  6. フォームの値を登録APIのリクエストボディの形式に整形
  7. APIを叩く

「4.」 と 「6.」 を Zod が担うことで、以下の2点の恩恵が得られます。

  1. 「4. バリデーションチェック」 を Zod のスキーマで定義できるようになり、フォームとバリデーションロジックを分離できる
  2. 「6. APIのリクエストボディの形式に整形」 を Zodtransform を用いることで 整形処理を Zod に委譲できる

以上より、我々のチームでは RHF に zod を取り入れています。 以前の記事でも動機を説明していたので、詳しくは以下の記事を参照してください。

tech.buysell-technologies.com

RHF + Zod の課題

RHF + Zod の組み合わせは非常に強力ですが、実際に利用してみたところ以下の2点の課題が見受けられました。

  1. 型引数の設定が難しく、チームで用いるには学習コストが高い
  2. Zodtransform を使うと型が不一致になる

型引数の設定が難しく、チームで用いるには学習コストが高い

RHF + Zod における、型引数の煩雑さについて説明します。 フォームの状態遷移は、以下のようになります。

名称未設定ファイル-ページ4.drawio.png

まず、「フォームのデフォルト値」は、値が入っているかは不確定の型となります。 次に、バリデーションチェックを行うことで、narrowing された型になります。 最後に、transform により、リクエストボディの型に変換されます。

上記の型は、RHFZod のユーティリティー型を用いて表現できます。 具体的には、RHFDeepPartialZodz.input, z.output を用います。

名称未設定ファイル-ページ4のコピー.drawio (2).png

DeepPartial は、再帰的に値が入っているか不確定な型を表現できます。 z.inputz.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 を用いて型引数を設定する方法は、煩雑であることがわかります。

Zodtransform を使うと型が不一致になる

Zodtransform を利用した際の型不整合について説明します。 例として、姓と名を transform により結合する場合を考えます。

const schema = z
  .object({
    firstName: z.string().min(1),
    lastName: z.string().min(1),
  })
  .transform((val) => ({
    fullName: `${val.firstName} ${val.lastName}`,
  }));

バリデーション後の型と整形後の型は、z.inputz.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 を用いると型の不整合が起こります。 本件については、RHFIssue が発行されており、v8で修正予定となっています。

github.com

提案手法

以上の問題点を解消するため、弊社では独自のユーティリティーを自作しました。それぞれの問題をどのように改善したか説明していきます。

github.com

型引数の煩雑さを解消する方法

  1. Zod のスキーマを引数とし、部分適用された useForm を返すユーティリティー (createZodForm) を作成
  2. ユーティリティー内で 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 の型不整合を解消する方法

  1. zodResolver の rawValues オプションを有効にする
  2. 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) は、onValiddataZodparse メソッドを実行した結果を渡しています。これにより、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: "..." }
});

createZodFormimport し、schema を引数に渡すことで型引数が設定された useForm を取得できます。 型引数を自身で行う場合と比較すると、schema を渡すことだけで設定できるため、簡単に設定できることがわかります。

整形後の型不整合に関しては、handleParsed という関数を提供することで解決しています。 handleSubmit を利用した場合と比較すると、型不整合が解消されていることがわかります。

Q & A

Q1. Zod の deepPartial 使わない理由はありますか?

ZoddeepPartial を使うと、バリデーションチェック後の型が DeepPartial 型になるためです。 期待値は、デフォルト値が DeepPartial 型で、バリデーションチェック後の方は DeepPartial が外れた型となります。

Q2. useZodForm の返り値として、 handleSubmit も提供しているのは何故ですか?

handleSubmit を提供しない場合、既存の useForm の返り値の型と互換性が取れなくなります。 そのため、返り値をそのまま利用するインタフェースで実装している箇所で後方互換性が取れなくなります。 例えば、useForm の返り値を FormProvider に対して、スプレッド演算子で展開しながら渡す場合などが挙げられます。

const useForm = createZodForm(schema)
const methods = useForm()

// ❌ handleSubmit をオーバーライドすると型不整合が起きる
<FormProvider {...methods} />

まとめ

RHF + Zod における型不整合を解消するためのユーティリティーを作りました。 複雑な型引数の設定をユーティリティーで隠蔽しているため、初学者でも簡単に利用できるようになりました。

このパッケージが同様の悩みを抱えている方の助けになればと思っております。

BuySell Technologiesではエンジニアを募集しています。こういった取組に興味がある方はぜひご応募をお願いします!

herp.careers

参考

react-hook-form.com

github.com

github.com