ReactのフォームバリデーションをZodで書く構成は、react-hook-form の resolver にZodスキーマを渡す形がほぼ定番になっています。問題は、2025年7月10日(UTC では7月9日)にリリースされたZod 4でエラーメッセージの指定方法が変わったことです。Zod 3の書き方で残した required_error は、Zod 4では例外にもならず黙って無視されます。用意したはずの日本語メッセージが英語の既定文に戻るため、型チェックを通していないプロジェクトでは本番まで気づきません。
この記事は zod 4.4.3・react-hook-form 7.85.0・@hookform/resolvers 5.7.1 を実際にインストールして動かした結果をもとに、連携手順からカスタムバリデーション、日本語化、移行で壊れる箇所までを扱います。Zodそのもののインストール手順や基本APIは Zodの使い方入門 にまとめてあります。
まとめ
ReactでZodを使うフォームバリデーションの要点は5つです。
- 連携に必要なのは react-hook-form・zod・@hookform/resolvers の3つ。resolvers 5系は react-hook-form 7.55.0 以上を要求します。
- Zod 4のエラーメッセージ指定は
errorに一本化されました。Zod 3のrequired_errorとinvalid_type_errorは、実行時には警告もなく無視されます(tscにかければTS2769で検出できます)。 - 日本語化は
z.config(z.locales.ja())の1行で効きますが、必須・文字数のメッセージは開発者向けの文面です。ユーザーに見せる項目は個別指定が要ります。 - 複数フィールドをまたぐ検証は
.refine()にpathを添えます。複数のissueを積む.superRefine()はZod 4でも現役で、非推奨ではありません。 - 4項目スキーマの safeParse を20万回動かした実測では、Zod 4.4.3 は Zod 3.25.76 の約1.4倍の速さでした。
以下、それぞれを実行結果つきで見ていきます。
React Hook FormとZodをつなぐzodResolverの実装手順
react-hook-form は入力値の保持と再レンダー制御を担当し、値が妥当かどうかの判断はZodに任せる、という分担になります。両者をつなぐのが @hookform/resolvers が提供する zodResolver です。react-hook-form 単体の register や watch の使い方は React Hook Formの使い方とv7の書き方 を参照してください。
パッケージの版とpeer依存の対応
3つのパッケージには相互の版制約があります。2026年8月12日時点の npm registry で確認した最新版と、@hookform/resolvers 5.7.1 が宣言している peerDependencies は次のとおりです。
| パッケージ | 最新版 | 公開日 | resolvers 5.7.1 が要求する版 |
|---|---|---|---|
| zod | 4.4.3 | 2026-05-04 | ^3.25.0 || ^4.0.0 |
| react-hook-form | 7.85.0 | 2026-08-08 | ^7.55.0 |
| @hookform/resolvers | 5.7.1 | 2026-08-02 | – |
注目すべきは react-hook-form の下限です。resolvers は5系の最初のリリースである 5.0.0(2025-04-01)から react-hook-form 7.55.0 を前提にしており、7.54以下に留めているプロジェクトは resolvers を5系へ上げられません。厳密には 5.0.0 だけは peer が キャレット無しの 7.55.0 で、7.56以上も弾きます。キャレット付きの範囲指定になるのは 5.0.1(2025-04-02)からです。いずれにせよ react-hook-form を先に上げてから resolvers を上げる順序になります。zod 側は 3.25.0 以上なら4系でなくても受け付けるため、Zodの移行とresolversの更新は分けて進められます。
npm install [email protected] [email protected] @hookform/[email protected]
サインアップフォームの実装
メールアドレス・パスワード・確認用パスワードを持つフォームを例にします。スキーマを1つ定義し、そこから型を導出して useForm に渡す、という流れです。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const signUpSchema = z
.object({
email: z.email({ error: "メールアドレスの形式が正しくありません" }),
password: z.string().min(8, { error: "8文字以上で入力してください" }),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
error: "パスワードが一致しません",
path: ["confirm"],
});
type SignUpInput = z.infer<typeof signUpSchema>;
async function postSignUp(values: SignUpInput): Promise<void> {
await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
}
export function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignUpInput>({
resolver: zodResolver(signUpSchema),
mode: "onBlur",
});
return (
<form onSubmit={handleSubmit((values) => postSignUp(values))}>
<input type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="password" {...register("confirm")} />
{errors.confirm && <p role="alert">{errors.confirm.message}</p>}
<button type="submit" disabled={isSubmitting}>登録</button>
</form>
);
}
このコードは typescript の strict: true ・ jsx: react-jsx 設定で tsc --noEmit を通過します(終了コード0)。ポイントは3つです。メールアドレスは z.string().email() ではなくトップレベルの z.email() を使うこと。エラーメッセージは message ではなく error で渡すこと。そして mode: "onBlur" を指定して、入力中ではなくフォーカスが外れた時点で検証を走らせることです。既定の onSubmit のままだと、送信ボタンを押すまでエラーが一切出ません。
zodResolverが返すエラーオブジェクトの中身
errors.email.message に何が入るのかは、resolver を直接呼べば確認できます。zodResolver が返す関数に不正な値を渡した実際の出力が次です。
// zodResolver(signUpSchema) を直接実行した結果(zod 4.4.3 / resolvers 5.7.1)
{
"values": {},
"errors": {
"email": { "message": "メールアドレスの形式が正しくありません", "type": "invalid_format" },
"password": { "message": "8文字以上で入力してください", "type": "too_small" },
"confirm": { "message": "パスワードが一致しません", "type": "custom" }
}
}
検証を通った場合は errors が空オブジェクトになり、values に変換後の値が入ります。type にはZodのissueコードがそのまま入るので、メッセージ文字列ではなく errors.password?.type === "too_small" のような形で分岐を書けます。ただし、この type はZodの版で変わる点に注意してください。同じ検証をZod 3.25.76のスキーマで実行したときは invalid_string でした。issueコードで分岐しているコードは、Zod 4へ上げた時点で条件が静かに外れます。
文字列で届くフォーム入力をZodスキーマで受ける型設計
HTMLのフォーム部品が返す値は、number型のinputであっても常に文字列です。この性質を無視してスキーマを書くと、型は通るのに実行時に落ちる、あるいはその逆が起きます。
数値入力とz.coerceの落とし穴
年齢や個数のような数値項目には z.coerce.number() を使います。ただし、変換が先に走ることによる副作用があります。
const ageSchema = z.coerce.number().int().min(1, { error: "1以上で入力してください" });
ageSchema.safeParse("5"); // -> data: 5
ageSchema.safeParse(""); // -> error: "1以上で入力してください"(型エラーではない)
空文字は Number("") が0を返すため、未入力が「0という数値が入力された」として扱われます。返るメッセージは「必須です」ではなく min の側です。未入力と0を区別したいフォームでは、z.coerce の前に空文字を弾く段を挟むか、任意項目なら z.literal("") との union にします。文字列の任意項目についても同じで、z.string().min(3).or(z.literal("")) とすれば空欄を許容しつつ、入力があるときだけ3文字以上を要求できます。実行すると空文字と3文字は通過し、2文字は不合格になりました。
入力型と出力型が分かれるスキーマの型引数
z.coerce や .default() を含むスキーマでは、検証前の型と検証後の型が一致しません。z.infer は出力型を返すため、useForm<z.infer<typeof schema>> と書くとフォーム側の型が実態とずれます。react-hook-form の3つの型引数で入出力を明示するのが正解です。
const profileSchema = z.object({
nickname: z.string().default("ゲスト"),
age: z.coerce.number().int().min(1, { error: "1以上で入力してください" }),
});
// 第1型引数=フォームが扱う入力型、第3型引数=検証後にhandleSubmitへ渡る出力型
const { register, handleSubmit } = useForm<
z.input<typeof profileSchema>,
unknown,
z.output<typeof profileSchema>
>({ resolver: zodResolver(profileSchema) });
この書き分けをしておくと、register("age") が扱うのは文字列、handleSubmit のコールバックが受け取るのは数値、と両方が型で保証されます。なお Zod 4 では .default() の挙動自体も変わり、既定値はスキーマの変換を通さずそのまま返るようになりました。変換を通したい場合は新設の .prefault() を使います。
refineとcheckによるカスタムバリデーションの実装
「パスワードと確認用が一致すること」「開始日が終了日より前であること」のように、単一フィールドの制約では書けない条件はカスタムバリデーションで表現します。Zod 4ではこの領域に .refine() ・ .superRefine() ・ .check() の3つが並びます。
複数フィールドをまたぐ検証とpath指定
.refine() はオブジェクト全体を受け取り、真偽値を返します。重要なのは path の指定です。
z.object({ password: z.string().min(8), confirm: z.string() })
.refine((v) => v.password === v.confirm, {
error: "パスワードが一致しません",
path: ["confirm"],
});
path を省略するとどうなるかも実行して確かめました。issueのpathは空配列になり、resolver が返す errors は { "": { message: "パスワードが一致しません", type: "custom" } } でした。空文字をキーにしたエラーになるため、errors.confirm は undefined のままです。確認用入力欄の下には何も表示されません。path: ["confirm"] を付けた場合は issue が [["confirm"], "パスワードが一致しません"] になり、resolver 経由でも errors.confirm.message に届きました。一致確認のような「2つの値の関係」を検証するときは、エラーを表示したい側のフィールド名をpathに書く、と覚えるのが確実です。
superRefineとcheckの使い分け
1回の検証で複数のissueを積みたい場合は .superRefine() を使います。ここを取り違えている解説を見かけますが、.superRefine() はZod 4でも非推奨になっていません。4.4.3 の型定義を見ても、ZodString.email() には付いている @deprecated が superRefine には付いていません。Zod 4で追加された .check() について、公式ドキュメントは「.superRefine() より低レベルで一般に複雑。性能が効くコードパスでは速いが、そのぶん冗長」と説明しています。既定は .superRefine() で、速度が問題になった箇所だけ .check() に落とす、という順序になります。
その .check() の書き方が次です。引数の形が .superRefine() と違います。
const slotSchema = z
.object({ start: z.coerce.number(), end: z.coerce.number() })
.check((ctx) => {
if (ctx.value.start >= ctx.value.end) {
ctx.issues.push({
code: "custom",
message: "終了は開始より後の時刻にしてください",
input: ctx.value,
path: ["end"],
});
}
});
.superRefine((val, ctx) => ctx.addIssue(...)) の2引数・addIssue形式に対し、.check() は1つのコンテキストを受け取り、検証対象は ctx.value、issueの追加は ctx.issues.push() です。input プロパティが必須になっている点が見落としやすく、省くと TS2345 で「Property ‘input’ is missing」と出ます。上のコードで { start: 10, end: 9 } を検証すると end にメッセージが付き、{ start: 10, end: 11 } は通過しました。
移行時に効いてくるのは、コンテキストが持つプロパティの変化です。両方の版でキーを列挙したところ、zod 3.25.76 の superRefine は ["addIssue", "path"]、zod 4.4.3 の check は ["value", "issues"]、4.4.3 に残っている superRefine は ["value", "issues", "addIssue"] でした。Zod 4側にはどちらにも path がありません。つまり .check() へ移しても解決しない種類の変更です。ネストしたスキーマの中で自分の位置を ctx.path から組み立てていたコードは、Zod 4では実行時に undefined を触ることになります。.superRefine() に留まるか .check() へ移るかによらず、issueごとに path を明示して積む書き方へ変えてください。
z.locales.jaによる日本語エラーメッセージの一括設定
Zod 4には翻訳済みのロケールが同梱されており、その中に日本語があります。手元の 4.4.3 で z.locales のキーを数えたところ52件でした。設定はアプリの起動時に1行書くだけです。
import * as z from "zod";
z.config(z.locales.ja());
これで、メッセージを個別指定していないフィールドの文言が日本語になります。ただし品質は項目によって差があります。実行して得られた文面が次です。
| 検証 | z.locales.ja() の出力 | そのまま使えるか |
|---|---|---|
| z.email() | 無効なメールアドレス | 使える |
| z.string() に undefined | 無効な入力: stringが期待されましたが… | 要差し替え |
| z.string().min(8) | 小さすぎる値: stringは8文字以上である必要があります | 要差し替え |
| z.number() に文字列 | 無効な入力: 数値が期待されましたが… | 要差し替え |
メール形式のように短く言い切れる検証は問題ありません。一方、必須と文字数のメッセージには「string」という型名がそのまま出ます。これは開発者向けの文面であって、フォームの利用者に見せるものではありません。上の表で型名が露出したのは必須・文字数・型違いの3種でした。ロケールで全体を日本語に倒したうえで、この3種にあたる項目だけ error で個別に上書きする、という二段構えになります。ロケールを入れておく価値は、指定漏れが起きても英語が画面に出ないことにあります。
Zod 3からZod 4へ上げたときフォームで壊れる箇所
黙って無視されるrequired_errorとerrorMap
Zod 4.0.0 の npm 公開は 2025-07-09T18:47Z、日本時間では2025年7月10日です。2026年8月時点の最新は 4.4.3 です。フォーム関連で最も危険なのは、コンパイルエラーにも実行時エラーにもならず、静かに挙動だけが変わる変更です。実際に両方の版で同じスキーマを動かして確認しました。
const schema = z.string({ required_error: "必須です", invalid_type_error: "文字列で" });
schema.safeParse(undefined).error.issues[0].message;
// zod 3.25.76 -> "必須です"
// zod 4.4.3 -> "Invalid input: expected string, received undefined"
Zod 4は required_error と invalid_type_error を受け取っても例外を投げません。未知のキーとして無視し、既定の英語メッセージを返します。日本語メッセージを全項目に用意していた既存フォームでも、Zod 4へ上げた瞬間に、未入力エラーだけ英語に戻ります。旧APIの errorMap も同じで、渡しても既定メッセージが返りました。
救いは型チェックです。この2行を tsc --noEmit にかけると TS2769 が出て、「’required_error’ does not exist in type」と名指しされます(あわせて safeParse().error が optional であることによる TS2532 も出ます)。errorMap も同様に弾かれます。裏を返せば、危ないのは型チェックを通していない経路です。JavaScriptで書いている、Next.js の typescript.ignoreBuildErrors を有効にしている、CIで tsc を走らせていない、といったプロジェクトでは何の合図も出ないまま英語のメッセージが本番に出ます。移行時はまず required_error と invalid_type_error と errorMap をgrepしてください。書き換え先は error に統一されており、関数を渡せば未入力と型違いを1か所で分けられます。
// Zod 4 での書き換え
z.string({ error: (issue) => (issue.input === undefined ? "未入力です" : "文字列で入力してください") });
移行時に踏みやすい変更の一覧
そのほか、フォームで踏みやすい変更を整理します。
| Zod 3の書き方 | Zod 4での扱い | 置き換え先 |
|---|---|---|
| required_error / invalid_type_error | 無視される | error |
| errorMap | 無視される | error |
| z.string().email() | 動作するが非推奨 | z.email() |
| error.errors | undefined になる | error.issues |
| error.format() | 非推奨 | z.treeifyError() |
| error.flatten() | 非推奨 | z.flattenError() |
| .superRefine() の ctx.path | ctxからpathが消える | issueごとにpathを明示 |
| z.record(z.string()) | 実行時は通るが型は不可 | キーと値の2引数 |
表の error.flatten() については一次ソースが割れています。型定義の @deprecated は z.treeifyError() を案内しますが、フラットな { formErrors, fieldErrors } の形をそのまま引き継ぎたいなら z.flattenError() が対応します。
この中で error.errors だけは性質が違います。4.4.3 で参照すると undefined が返るため、error.errors.map(...) のようなコードは実行時に落ちます。逆に z.string().email() と z.record(z.string()) は実行時には正常に動きます。前者は process.emitWarning の呼び出し回数を数えても0、後者も { a: "x" } の検証に成功しました。ただし型チェックにかければ話は別で、z.record() の単一引数は TS2554 「Expected 2-3 arguments, but got 1」で落ちます。
z.string().email() のような非推奨APIも、放置してよいわけではありません。型定義に @deprecated が書かれているので、エディタでは取り消し線が付き、@typescript-eslint/no-deprecated を入れればCIでエラーにできます。手当てが要るのは、型チェックもLintも回していないプロジェクトです。そこでは grep が唯一の検出手段になります。
Zod 4へ上げるべきでないケース
では常にZod 4へ上げるべきかというと、そうとは限りません。上げるべきでないのは、react-hook-form を 7.55.0 以上に上げられない事情があるプロジェクトです。@hookform/resolvers 5系がその版を要求するため、resolvers を4系に据え置くことになります。4系の最終版は 4.1.3(2025-03-03)で、peerDependencies に zod の記載自体がなく、Zod 4.0.0 より4か月早く更新が止まっています。Zod 4スキーマとの組み合わせは、公式にサポートされた構成の外側です。この場合は zod 3.25 系に留めれば、公式にサポートされた組み合わせの内側にいられます。逆に react-hook-form を上げられるなら、新規プロジェクトでZod 3を選ぶ理由はもうありません。Zod 4のスキーマは Standard Schemaの全体像 に準拠しており、resolver を差し替えるだけで他のバリデーションライブラリへ乗り換えられる余地も残ります。
Zod 3系とZod 4系のバリデーション性能の実測
入力のたびに検証が走るフォームでは、スキーマの実行コストが体感に効きます。「Zodは軽量で高速」という説明はよく見かけますが、フォーム用途で実際にどれだけ差が出るのかは数字が出てきません。そこで同一環境で測りました。
条件は Node v26.5.0(x64)です。メール・文字列(1〜50文字)・整数(0〜120)・文字列配列(最大5件)の4項目を持つオブジェクトスキーマを両版で組み、メールだけは各版の書き方に合わせて zod 3 側を z.string().email()、zod 4 側を z.email() としました。同一プロセス内でウォームアップ2000回のあと safeParse を20万回計測し、これを3試行です。
| 試行 | zod 3.25.76 | zod 4.4.3 |
|---|---|---|
| 1回目 | 800ms | 564ms |
| 2回目 | 791ms | 549ms |
| 3回目 | 789ms | 546ms |
この環境ではZod 4がおよそ1.4倍の速さでした。1回あたりに直すとZod 4が約2.8マイクロ秒、Zod 3が約4.0マイクロ秒です。ただしこの比は環境に強く依存します。マシン・Node版・実行順を変えると2倍以上に開くこともあるので、有効数字を細かく受け取らないでください。この差はフォーム1画面の検証では体感できません。20万回という回数はサーバー側で大量のレコードを検証する規模を想定したもので、キー入力ごとに1回走る用途では両者に実用上の差はない、というのが率直な結論です。Zod 4を選ぶ理由は速度ではなく、エラーAPIの整理とロケール同梱にあります。速度が本当にボトルネックになる規模なら、バンドルサイズと実行速度を主眼に設計された ValibotとZodの違い を比較検討する段階です。
よくある質問
Zod 4でz.string().email()は使えなくなりましたか?
使えます。zod 4.4.3 で z.string().email().safeParse("[email protected]") を実行すると成功が返り、process.emitWarning の呼び出しも0回でした。非推奨扱いになったのは事実ですが、実行時には何も起きません。ただし新しく書くコードはトップレベルの z.email() を使ってください。z.uuid() や z.url() も同様にトップレベルへ移りました。なお z.uuid() は RFC 9562/4122 に沿った厳密な検証に変わっており、版番号の桁が規格外のUUIDは弾かれます。実際に 12345678-1234-f234-a234-123456789abc を試したところ、zod 3.25.76 は通し、zod 4.4.3 は不合格にしました。形式だけ合っていればよい既存データを扱う場合は、緩い判定の z.guid() に切り替えてください。同じ値を z.guid() で検証すると合格します。
zodResolverはZod 3のスキーマでも動きますか?
動きます。@hookform/resolvers 5.7.1 の peerDependencies は zod: ^3.25.0 || ^4.0.0 で、両系統を受け付けます。実際に zod 3.25.76 のスキーマを zodResolver に渡したところ、エラーメッセージは正しく取り出せました。ただし戻り値の type が異なり、Zod 3スキーマでは invalid_string、Zod 4スキーマでは invalid_format になります。issueコードで分岐しているなら、混在させずどちらかに揃えてください。
数値項目を空欄のまま送信すると0として通ってしまうのはなぜですか?
z.coerce.number() が空文字を0に変換しているためです。Number("") が0を返す挙動がそのまま出ています。z.coerce.number().int().min(1) に空文字を渡すと、必須エラーではなく min のエラーが返りました。未入力を明示的に扱いたい場合は、変換前に空文字を検出する段を挟むか、z.union([z.literal(""), z.coerce.number().min(1)]) のように空文字を別扱いにします。この形なら空文字と “5” は通過し、”0″ は不合格になります。
z.number().positive()とmin(1)はどう使い分けますか?
境界の扱いが違います。.positive() は0より大きいこと、.min(1) は1以上であることを要求します。整数だけを扱うなら結果は同じで、差が出るのは小数を許す項目です。実測では 0.5 が .positive() を通過し .min(1) で弾かれました。単価や数量のように小数を受け付ける項目では、意図した境界がどちらかを確認してください。
React Hook Form以外のフォームライブラリでもZodは使えますか?
使えます。@hookform/resolvers が対応しているのは react-hook-form ですが、Zodのスキーマは Standard Schema 仕様に準拠しているため、同仕様に対応したライブラリならアダプタなしで受け付けます。React向けでは TanStack Formでのzodバリデーション が代表例です。サーバー側で受ける場合は resolver を介さず schema.safeParse(body) を直接呼び、z.flattenError() でフィールドごとのメッセージ配列に変換すると、クライアントの表示ロジックをそのまま流用できます。