レビュー済み·難易度: 中級·更新: 2026-04-17

Zod スキーマをフォームバリデーションと API レスポンス検証で共有する

同一の Zod スキーマを React Hook Form のバリデーションと fetch レスポンス検証の両方で再利用し、型定義の重複をなくす実装例。

nextjsformvalidationapizodreact-hook-form

対応バージョン

nextjs 15react 19zod 3react-hook-form 7

前提環境

Zod の基本的なスキーマ定義と React Hook Form の zodResolver を理解していること

概要

Zod スキーマを一か所で定義し、フォームバリデーション(React Hook Form)と API レスポンス検証の両方で使い回す。 型定義と検証ロジックを重複させないことで、スキーマ変更時の修正コストを最小化できる。

インストール

npm install zod react-hook-form @hookform/resolvers

実装例

// src/lib/schemas/user.ts
import { z } from "zod";

// 共有スキーマ(フォームと API レスポンス両方で使う)
export const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1, "名前を入力してください"),
  email: z.string().email("正しいメールアドレスを入力してください"),
});

// フォーム用(id は不要)
export const UserFormSchema = UserSchema.omit({ id: true });

// API レスポンス用(配列)
export const UserListSchema = z.array(UserSchema);

export type User = z.infer<typeof UserSchema>;
export type UserFormValues = z.infer<typeof UserFormSchema>;
// src/components/UserForm.tsx(フォームバリデーションで使う)
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserFormSchema, type UserFormValues } from "@/lib/schemas/user";

export function UserForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserFormValues>({
    resolver: zodResolver(UserFormSchema),
  });

  const onSubmit = (data: UserFormValues) => {
    console.log("送信:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <input
          {...register("name")}
          placeholder="名前"
          className="w-full rounded border px-3 py-2 text-sm"
        />
        {errors.name && (
          <p className="mt-1 text-xs text-red-600">{errors.name.message}</p>
        )}
      </div>
      <div>
        <input
          {...register("email")}
          placeholder="メールアドレス"
          className="w-full rounded border px-3 py-2 text-sm"
        />
        {errors.email && (
          <p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
        )}
      </div>
      <button
        type="submit"
        className="rounded bg-blue-600 px-4 py-2 text-sm text-white"
      >
        送信
      </button>
    </form>
  );
}
// src/lib/api/users.ts(API レスポンス検証で同じスキーマを使う)
import { UserListSchema, type User } from "@/lib/schemas/user";

export async function fetchUsers(): Promise<User[]> {
  const res = await fetch("/api/users");
  if (!res.ok) throw new Error("取得に失敗しました");

  const json: unknown = await res.json();
  return UserListSchema.parse(json);
}

ポイント

  • スキーマを src/lib/schemas/ に分離することで、フォームと API の両方から import できる
  • omit / pick / extend で用途ごとに派生スキーマを作ると、差分だけ管理できる
  • z.infer<typeof Schema> で型を自動導出し、型定義を二重管理しない
  • フォームのバリデーションメッセージと API のエラーメッセージを同じスキーマで統一できる
  • スキーマ変更時は一か所を修正するだけで、フォームと API 両方の型・検証が更新される

注意点

フォーム用スキーマと API レスポンス用スキーマは完全に同一でなくても良い。pick / omit / extend で派生スキーマを作ると柔軟に対応できる。

関連サンプル