型付けを頑張る React Hook Form
はじめに
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
時に Zod や Yup などのスキーマバリデーションライブラリを使って実行時に値を検証してもいいですが、なるべくフールプルーフな設計に寄せたいことの方が多いでしょう。
解決策
それぞれのコンポーネントで型引数に 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
プロパティで渡されたフィールドが期待している型と、実際の型が異なる場合があることです。例えば age
は number
型を期待していますが、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" />
ソースコード全体はこちらで公開しています。
余談
React Hook Form が開発している strictly-typed
というライブラリがありますが、ほとんどメンテナンスされておらず執筆時の最新版である v7 に対応していなかったのと、自分が求めているものと少しずれていたため見送りました。
Discussion