Reactでreact-hook-formとzodを使い、型の付いたフォームを作る方法

​​React​React Hook Form,TypeScript,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 も合わせて、次のようにバージョンを指定して入れました。

text
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 です。

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 です。送信に成功したら、受け取ったデータを画面に出すようにしました。

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 の全体です。

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 のエラーも出ています)。

text
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つの欄すべてにメッセージが出ました。

text
[ '名前を入力してください', 'メールアドレスの形で入力してください', '年齢を数字で入力してください' ]

そのあと、名前の欄に1文字入れただけで、送信し直さなくても名前のメッセージが消えました。年齢の欄に「2」と入れた時点でも、送信し直さずに、メッセージが「年齢を数字で入力してください」から「18歳以上を入力してください」に変わりました(このあとの GIF の4回目)。react-hook-form の mode の初期値は onSubmit で、最初の検証は送信のときに行われます。送信のあとは reValidateMode の初期値の onChange に従い、エラーのある欄が入力のたびに検証し直されます。

すべての欄を正しく埋めて送信すると、メッセージが消え、次のデータが届きました。age は数値です。

text
{"name":"たま","email":"tama@example.com","age":20}(age の型:number)

この操作の流れを GIF にしました。

登録フォームを5回操作した画面。1回目に空のまま送信すると、3つの欄の下に新しいメッセージがピンクで出る。2回目から4回目で名前、メール、年齢を入れると、入れた欄のメッセージが送信し直さなくても消える。年齢は2を入れた時点でメッセージが「18歳以上を入力してください」に変わってピンクになり、20で消える。5回目にもう一度送信すると、届いたデータがピンクで表示され、ageの型がnumberと出る
空のまま送信してから3つの欄を埋め、もう一度送信するまでの動き

数値の入力欄は、そのままだと文字列で届く

SignupForm.tsx の年齢の欄には、register('age', { valueAsNumber: true }) と書きました。これを付け忘れて register('age') にしたフォームで、年齢に「20」と入れて送信すると、次のメッセージが出ました。

text
[ '年齢を数字で入力してください' ]

このとき、handleSubmit の2つ目の引数(検証に通らなかったときに呼ばれる関数)で、フォームが持っている年齢の値を出してみました。type="number" の欄でも、値は文字列の "20" でした。

text
age の値: "20" 型: string

zod だけで確かめると、z.number() に文字列の "20" を渡したときは、次のメッセージでエラーになりました。

text
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} が届きました。

一方で、年齢を空のまま送信すると、エラーにならずに次のデータが届きました。

text
{"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行目までです。

tsx
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 に代入できない、という詳細が続きます)。

text
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つを書く次の書き方はエラーになりませんでした。

tsx
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つだけ書くと型エラーになることに気をつけてください。

​Posted by 86