Reactでreact-hook-formとzodを使い、型の付いたフォームを作る方法
zod でスキーマを1つ書き、zodResolver で react-hook-form につなぐと、入力の検証とフォームの値の型が、同じスキーマから決まります。useForm に型を書かなくても、register に渡せる名前や、送信時に受け取るデータの型が付きます。
ただし、数値の入力欄の値は文字列で届くので、valueAsNumber か z.coerce で数値にする必要があります。筆者は、valueAsNumber と z.coerce の違いも含めて、検証環境で型チェックと画面の動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、npm 10.9.4、Chromium 141 です。使ったのは react / react-dom 19.3.1、react-hook-form 7.90.0、zod 4.6.7、@hookform/resolvers 5.9.2、typescript 7.0.2 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、Playwright で操作した Chromium で動かしました。
@hookform/resolvers の最新版は、2026年10月22日の時点で 6.0.0 です。npm i react-hook-form zod @hookform/resolvers で入れようとしたところ、検証環境では ERESOLVE のエラーで止まったため、5.9.2 を指定して入れています。6.0.0 での動きは確かめていません。
使うパッケージ
フォームの状態を持つ react-hook-form、スキーマを書く zod、その2つをつなぐ @hookform/resolvers の3つを入れます。検証では、react と react-dom も合わせて、次のようにバージョンを指定して入れました。
npm i react@19.3.1 react-dom@19.3.1 react-hook-form@7.90.0 zod@4.6.7 @hookform/resolvers@5.9.2
スキーマを書き、型をそこから作る
例として、名前、メールアドレス、年齢を入れる登録フォームを作ります。まず、zod で入力のルールを書きます。次のコードは schema.ts です。
import { z } from 'zod';
export const signupSchema = z.object({
name: z.string().min(1, '名前を入力してください'),
email: z.email('メールアドレスの形で入力してください'),
age: z.number('年齢を数字で入力してください').int('整数で入力してください').min(18, '18歳以上を入力してください'),
});
export type SignupValues = z.infer<typeof signupSchema>;
z.string() や z.number()、.int() や .min() などに渡した文字列は、検証に通らなかったときのメッセージになります。メールアドレスは、zod 4 では z.email() で書けます。
最後の行の z.infer で、スキーマから TypeScript の型を取り出しています。SignupValues は { name: string; email: string; age: number } になり、型を別に書く必要はありません。
useForm に zodResolver を渡す
スキーマを zodResolver で包み、useForm の resolver に渡します。次のコードは SignupForm.tsx です。送信に成功したら、受け取ったデータを画面に出すようにしました。
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, type SignupValues } from './schema';
export function SignupForm() {
const [sent, setSent] = useState<SignupValues | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(signupSchema),
defaultValues: { name: '', email: '' },
});
return (
<form onSubmit={handleSubmit((data) => setSent(data))} noValidate>
<label>
名前
<input {...register('name')} />
</label>
{errors.name && <p className="error">{errors.name.message}</p>}
<label>
メール
<input type="email" {...register('email')} />
</label>
{errors.email && <p className="error">{errors.email.message}</p>}
<label>
年齢
<input type="number" {...register('age', { valueAsNumber: true })} />
</label>
{errors.age && <p className="error">{errors.age.message}</p>}
<button type="submit">登録する</button>
{sent && <pre id="sent">{JSON.stringify(sent)}(age の型:{typeof sent.age})</pre>}
</form>
);
}
useForm には型引数を書いていません。@hookform/resolvers の README にある TypeScript の説明のとおり、型は resolver に渡したスキーマから推論されます。handleSubmit に渡した関数の data も SignupValues と同じ形になるので、setSent(data) はそのまま型チェックを通りました。
<form> の noValidate は、送信するときにブラウザが検証しないようにする属性です(MDN の form 要素の説明)。チェックを zod のスキーマにまとめ、メッセージも zod のものにそろえるために付けました。
型チェックで見つかる書き間違い
型が付いていると、フォームの名前の打ち間違いや、値の型の取り違えを tsc が見つけます。次のコードは、わざと間違いを2つ入れたもので、typecheck/mistakes.tsx の全体です。
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema } from '../src/schema';
export function Mistakes() {
const { register, handleSubmit } = useForm({ resolver: zodResolver(signupSchema) });
return (
<form
onSubmit={handleSubmit((data) => {
data.age.toUpperCase();
})}
>
<input {...register('nmae')} />
</form>
);
}
tsc を実行すると、2か所ともエラーになりました。次の2行は、出力のうち mistakes.tsx の分です(同じ実行で、このあと取り上げる coerce-generic.tsx のエラーも出ています)。
typecheck/mistakes.tsx(10,18): error TS2339: Property 'toUpperCase' does not exist on type 'number'.
typecheck/mistakes.tsx(13,27): error TS2345: Argument of type '"nmae"' is not assignable to parameter of type '"age" | "email" | "name"'.
register に渡せるのは、スキーマにある "age" | "email" | "name" だけです。送信時の data.age は number なので、文字列のメソッドは呼べません。
画面での動き
検証環境の Chromium で、このフォームを操作しました。何も入れずに「登録する」を押すと、3つの欄すべてにメッセージが出ました。
[ '名前を入力してください', 'メールアドレスの形で入力してください', '年齢を数字で入力してください' ]
そのあと、名前の欄に1文字入れただけで、送信し直さなくても名前のメッセージが消えました。年齢の欄に「2」と入れた時点でも、送信し直さずに、メッセージが「年齢を数字で入力してください」から「18歳以上を入力してください」に変わりました(このあとの GIF の4回目)。react-hook-form の mode の初期値は onSubmit で、最初の検証は送信のときに行われます。送信のあとは reValidateMode の初期値の onChange に従い、エラーのある欄が入力のたびに検証し直されます。
すべての欄を正しく埋めて送信すると、メッセージが消え、次のデータが届きました。age は数値です。
{"name":"たま","email":"tama@example.com","age":20}(age の型:number)
この操作の流れを GIF にしました。

数値の入力欄は、そのままだと文字列で届く
SignupForm.tsx の年齢の欄には、register('age', { valueAsNumber: true }) と書きました。これを付け忘れて register('age') にしたフォームで、年齢に「20」と入れて送信すると、次のメッセージが出ました。
[ '年齢を数字で入力してください' ]
このとき、handleSubmit の2つ目の引数(検証に通らなかったときに呼ばれる関数)で、フォームが持っている年齢の値を出してみました。type="number" の欄でも、値は文字列の "20" でした。
age の値: "20" 型: string
zod だけで確かめると、z.number() に文字列の "20" を渡したときは、次のメッセージでエラーになりました。
z.number() に "20": Invalid input: expected number, received string
valueAsNumber: true を付けると、react-hook-form が検証の前に値を数値にします(register の説明)。数値にできないときは NaN になり、z.number() は NaN を受け付けません。そのため、年齢を空のまま送信したときは「年齢を数字で入力してください」が出ました。
z.coerce.number() を使うときの注意
valueAsNumber の代わりに、スキーマ側で z.coerce.number() を使って、文字列を数値に変える書き方もあります。検証環境で、年齢だけのフォームを z.coerce.number().int().min(0) で作りました(メッセージの引数は省いて書いています)。「20」と入れて送信すると {"age":20} が届きました。
一方で、年齢を空のまま送信すると、エラーにならずに次のデータが届きました。
{"age":0}
z.coerce.number() は値を Number() で変換するので、空の文字列は 0 になります。zod だけで確かめても、z.coerce.number() に "" を渡すと 0 が返りました。空欄を許したくない欄では、この書き方だと空欄を見逃します。
z.coerce.number() は空欄を 0 として通します。必須の数値の欄では、valueAsNumber と z.number() の組み合わせのほうが、空欄をエラーにできます。
型にも違いが出ます。z.coerce.number() の入力の型は、zod のドキュメントのとおり unknown です。出力の型は number なので、スキーマの入力と出力で型が分かれます。この状態で、useForm に z.infer の型を1つだけ書いてみました。次のコードは、型チェック用のファイル typecheck/coerce-generic.tsx の先頭から13行目までです。
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const coerceSchema = z.object({
age: z.coerce.number().min(0),
});
type CoerceValues = z.infer<typeof coerceSchema>;
export function SingleGeneric() {
const form = useForm<CoerceValues>({ resolver: zodResolver(coerceSchema) });
return null;
}
tsc を実行すると、次のエラーになりました。エラーの1行目だけを載せます(このあとに、age の unknown を number に代入できない、という詳細が続きます)。
typecheck/coerce-generic.tsx(11,40): error TS2322: Type 'Resolver<{ age: unknown; }, any, { age: number; }>' is not assignable to type 'Resolver<{ age: number; }, any, { age: number; }>'.
zodResolver は、入力の型({ age: unknown })と出力の型({ age: number })を分けて推論します。useForm に型を1つだけ書くと、その2つが同じ型に固定されてしまい、合わなくなります。@hookform/resolvers の README の zod の説明にも、.default() を使ったスキーマで同じことが起きると書かれています。
直し方は2つあります。型引数を書かずに推論に任せるか、入力・コンテキスト・出力の3つを書くかです。検証環境では、3つを書く次の書き方はエラーになりませんでした。
const form = useForm<z.input<typeof coerceSchema>, unknown, z.output<typeof coerceSchema>>({
resolver: zodResolver(coerceSchema),
});
推論に任せた場合も、フォームの値と送信時のデータで型が分かれます。getValues('age') で読んだ値は unknown なので、.toFixed() を呼ぶと error TS2571: Object is of type 'unknown'. になりました。handleSubmit に渡した関数の data.age は number なので、.toFixed() を呼べました。
型をスキーマに任せると、ずれが起きにくい
react-hook-form と zod で型の付いたフォームを作るときは、zod のスキーマを1つ書き、useForm({ resolver: zodResolver(schema) }) のように、型引数を書かずに渡すのが手軽です。検証では、それだけで register の名前の打ち間違いや、送信時のデータの型の取り違えが tsc のエラーになりました。数値の入力欄は、valueAsNumber: true と z.number() を組み合わせると、空欄もエラーにでき、送信時のデータも数値になりました。z.coerce.number() を使う場合は、空欄が 0 になることと、useForm に型を1つだけ書くと型エラーになることに気をつけてください。





