型付けを頑張る React Hook Form

2022/01/11に公開

はじめに

React Hook Form は Controlled component と比べると利便性は高いですが、代償として型安全性をある程度放棄しています。この記事では React Hook Form を使いながらも型安全性を可能な限り高めるための解決策を紹介しています。

この記事で扱わないこと

  • フォームライブラリを使うことの是非
  • React Hook Form の基本的な使い方
  • 本題から逸れるコンポーネント設計の話

解決したいこと

次のような TextInput コンポーネント、NumberInput コンポーネントと、それらを使う Form コンポーネントについて考えます。

type TextInputProps = {
  name: string;
};

const TextInput: React.VFC<TextInputProps> = ({ name }) => {
  const { register } = useFormContext();
  return <input type="text" {...register(name)} />;
};
type NumberInputProps = {
  name: string;
};

const NumberInput: React.VFC<NumberInputProps> = ({ name }) => {
  const { register } = useFormContext();
  return <input type="number" {...register(name, { valueAsNumber: true })} />;
};
type FormData = {
  username: string;
  age: number;
};

const Form: React.VFC = () => {
  const methods = useForm<FormData>();
  const handleSubmit = methods.handleSubmit((data) => {
    console.log(data);
  });

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit}>
        <TextInput name="username" />
        <NumberInput name="age" />
        <input type="submit" />
      </form>
    </FormProvider>
  );
};

このとき TextInput コンポーネントの name プロパティは、FormData に基づくフィールド名(この場合 "username" | "age")であることが期待されていますが、次のように存在しないフィールド名も渡せてしまいます。

// エラーにならない
<TextInput name="foobar" />
<NumberInput name="hogefuga" />

handleSubmit 時に ZodYup などのスキーマバリデーションライブラリを使って実行時に値を検証してもいいですが、なるべくフールプルーフな設計に寄せたいことの方が多いでしょう。

解決策

それぞれのコンポーネントで型引数に FormData を受け付けるようにします。

import type { Path, FieldValues } from "react-hook-form";

- type TextInputProps = {
-   name: string;
+ type TextInputProps<T> = {
+   name: Path<T>;
};

- const TextInput: React.VFC<TextInputProps> = ({ name }) => {
+ const TextInput = <T extends FieldValues = never>({
+   name,
+ }: TextInputProps<T>): ReturnType<React.VFC> => {
  const { register } = useFormContext();
  return <input type="text" {...register(name)} />;
};

// NumberInput も同様

Path 型と FieldValues 型はそれぞれ React Hook Form が提供している型です。Path 型は、フォームの型を受け取り、有効なフィールド名を返す型です。

type Field = Path<{
  foo: string;
  bar: number[];
  baz: {
    a: boolean;
  }[];
}>;

// type Field = "foo" | "bar" | "baz" | `bar.${number}` | `baz.${number}` | `baz.${number}.a`

型引数の制約に使われている FieldValues 型は useForm の型引数の制約と同じ型で、実体は Record<string, any> です。デフォルト値に never を指定することで、型引数を省略した場合にエラーを発生させることができます。

これで、不正な name プロパティを渡したり、型引数を忘れたりするとエラーが発生するようになりました。エディタ上で補完も効きます。

// エラー
<TextInput name="foobar" />
<NumberInput name="hogefuga" />
<TextInput name="username" />
<NumberInput name="age" />
<TextInput<FormData> name="usrnm" />
<NumberInput<FormData> name="ageee" />

// OK
<TextInput<FormData> name="username" />
<NumberInput<FormData> name="age" />

しかし、まだ問題があります。それは name プロパティで渡されたフィールドが期待している型と、実際の型が異なる場合があることです。例えば agenumber 型を期待していますが、TextInput コンポーネントに name プロパティとして渡すことができてしまいます。

// エラーにならない
<TextInput<FormData> name="age" />

これを解決するために、TextInput コンポーネントでは string 型のフィールドのみ、NumberInput コンポーネントでは number 型のフィールドのみそれぞれ受け付けるようにします。次のように、フォームの型と任意の型を渡すと、任意の型を満たすフィールド名を返す型 FieldByType を定義します。

import type { FieldPathValue, Path } from "react-hook-form";

type FieldByType<FormData, T> = {
  [P in Path<FormData>]: T extends FieldPathValue<FormData, P> ? P : never;
}[Path<FormData>];

FieldPathValue 型も React Hook Form が提供する型です。FieldPathValue 型は、フォームの型とフィールド名を渡すとそのフィールドの値の型を返す型です。

type Value = FieldPathValue<
  {
    foo: string;
    bar: number[];
    baz: {
      a: boolean;
    }[];
  },
  `baz.${number}.a`
>;

// type Value = boolean;

先ほど定義した FieldByType 型を使うと、次のようにして指定した型のフィールド名を取り出すことができます。

type Field = FieldByType<
  {
    foo: string;
    bar: number[];
    baz: {
      a: boolean;
      b: number;
      c: string;
    };
    hoge: number;
    fuga: string[];
  },
  string
>;

// type Field = "foo" | "baz.c" | `fuga.${number}`;

そして name プロパティの型をそれぞれ次のように変更します。

type TextInputProps<T> = {
-   name: Path<T>;
+   name: FieldByType<T, string>;
};

type NumberInputProps<T> = {
-   name: Path<T>;
+   name: FieldByType<T, number>;
}

これで、フィールド名とそのフィールドの値の型が一致しない場合はエラーにすることができました。

// エラー
<TextInput<FormData> name="age" />
<NumberInput<FormData> name="username" />

// OK
<TextInput<FormData> name="username" />
<NumberInput<FormData> name="age" />

ソースコード全体はこちらで公開しています。

https://github.com/dqn/react-hook-form-typing-example

余談

React Hook Form が開発している strictly-typed というライブラリがありますが、ほとんどメンテナンスされておらず執筆時の最新版である v7 に対応していなかったのと、自分が求めているものと少しずれていたため見送りました。

https://github.com/react-hook-form/strictly-typed

GitHubで編集を提案

Discussion