© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsform

Zustand Test の Zod スキーマを refine と superRefine で拡張する

単体フィールドでは表現できない条件付き検証や cross-field バリデーションを refine / superRefine / discriminatedUnion で書き分ける手順を解説する。

難易度: 中級·更新: 2026-05-07·
zodreact-hook-form

対応バージョン

nextjs 15react 19zod 4

前提環境

Zod の z.object と z.infer の基本を理解していること

概要

Zustand Test の formSchema は最小限の min / max で済んでいますが、現実的な要件では次のような検証が増えていきます。

パターン例使うもの
単一フィールドの追加検証「メールアドレスがブラックリストにない」refine
複数フィールドの突合「password と confirm が一致する」superRefine
モードによって形が変わるmode === "draft" のときは rate を必須にしないdiscriminatedUnion
ランタイムで条件分岐API 設定で必須項目が変わるスキーマ生成関数

すべて errors.<field>.message で受け取れる形に揃えます。

インストール

npm install zod

refine: 単一フィールドの追加検証

refine は 「条件 → エラーメッセージ」 の組を 1 つ追加するための API です。フィールド単独で完結する検証に使います。

import { z } from "zod";

const BANNED = ["test@example.com", "spam@example.com"];

export const formSchema = z.object({
  email: z
    .string()
    .email("有効なメールアドレスを入力してください")
    .refine((v) => !BANNED.includes(v.toLowerCase()), {
      message: "このメールアドレスは利用できません",
    }),
});

refine は前段の検証(email() など)が通ってから走ります。前段で失敗したら refine は実行されない ので、v の型は信頼できます。

superRefine: 複数フィールドの突合

refine は 1 つのメッセージしか出せません。複数フィールドを見比べる、あるいは 複数のエラーをまとめて出したい 場合は superRefine を使います。

import { z } from "zod";

export const passwordChangeSchema = z
  .object({
    password:        z.string().min(8, "8 文字以上で入力してください"),
    confirmPassword: z.string(),
  })
  .superRefine((values, ctx) => {
    if (values.password !== values.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "確認用パスワードが一致しません",
        path:   ["confirmPassword"],
      });
    }
  });

ポイントは 2 つ。

  • path: ["confirmPassword"] を指定すると、エラーが errors.confirmPassword.message に届く
  • path を省略するとフォーム全体(root)扱いになり、errors.root に入る

ctx.addIssue は何度でも呼べるので、複数の問題を一度に通知できます。

discriminatedUnion: モードで形を変える

mode のような 判別キー で、必要なフィールドが切り替わるフォームには discriminatedUnion が向きます。型レベルで「mode === "fixed" のときは rate が必須」を表現できます。

import { z } from "zod";

export const detailSchema = z.discriminatedUnion("mode", [
  z.object({
    mode:  z.literal("draft"),
    note:  z.string().optional(),
  }),
  z.object({
    mode:  z.literal("review"),
    note:  z.string().min(1, "コメントを入力してください"),
  }),
  z.object({
    mode:  z.literal("fixed"),
    rate:  z.coerce.number().min(0).max(100),
  }),
]);

export type DetailData = z.infer<typeof detailSchema>;
// DetailData は 3 通りの形を持つ union 型

UI 側は useForm<DetailData> で受け取るだけで、TypeScript が mode の値ごとに利用できるフィールドを絞り込み ます。if (data.mode === "fixed") の中でだけ data.rate が見える状態になります。

動的スキーマ: 設定によってフィールドの必須が変わる

API から取得した設定で「rate は必須かどうか」が変わるような場合は、スキーマを返す関数 にします。

type SchemaConfig = { rateRequired: boolean };

export function buildFormSchema(config: SchemaConfig) {
  const rate = config.rateRequired
    ? z.coerce.number().min(0).max(100)
    : z.coerce.number().min(0).max(100).optional();

  return z.object({
    name:  z.string().min(1, "名前を入力してください"),
    email: z.string().email("有効なメールアドレスを入力してください"),
    rate,
  });
}

export type FormData = z.infer<ReturnType<typeof buildFormSchema>>;

useForm 側はメモ化を忘れずに行います。毎レンダリングで新しいスキーマを作る と、zodResolver が毎回別関数になり、検証の挙動が乱れます。

import { useMemo } from "react";

const schema = useMemo(() => buildFormSchema({ rateRequired }), [rateRequired]);
const form   = useForm<FormData>({ resolver: zodResolver(schema) });

エラーが UI に届く流れ

refine / superRefine のエラーは、zodResolver が RHF の errors に変換します。path が指定されていれば対応するフィールドへ、なければ errors.root に入ります。

Zod 側RHF 側
refine の messageerrors.<field>.message
superRefine の path: ["x"]errors.x.message
superRefine の path 省略errors.root.message
discriminatedUnion の判別ミスerrors.<判別キー>.message

エラー UI / aria 編 で書いたとおり、UI 側は どの拡張で出したエラーでも同じ表示パターン で扱えます。

ポイント

  • 単一フィールドの追加検証は refine、複数フィールドの突合 は superRefine(path で対象を指定)
  • 判別キーで形が変わるフォームは discriminatedUnion。mode の値で型レベルに絞り込み されるのが強み
  • 設定で必須項目が変わるなら スキーマを返す関数 にし、useMemo で参照を安定させる
  • refine / superRefine のメッセージは zodResolver が errors.<path>.message にそのまま変換する
  • 拡張しても UI 側のエラー表示は変えない。スキーマだけ更新すれば UI が追従する 設計を保つ

関連記事

セットで読むと文脈がつながる関連記事は次のとおりです。

  • Zod 編 — 基本の z.object と coerce
  • TypeScript 編 — z.infer と discriminated union の型
  • エラー UI / aria 編 — errors.<field>.message の表示
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground 追加記事。基本の min/max では足りなくなったときに使う 4 つの拡張パターン(refine / superRefine / discriminated union / 動的スキーマ)を整理する。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test の Zod / RHF watch / patchFormData と setFormData を読み解く

    RHF の watch でリアルタイム同期しつつ、保存時に Zod parse で確定値を書き戻す Playground のフォーム実装を1ステップずつ解説する。

  • React Hook Form の useFieldArray で動的フィールドを追加・削除する

    useFieldArray を使い、フォーム内のフィールドを動的に追加・削除・並び替えする実装例。Zod で配列バリデーションも行う。

  • React Hook Form でファイルアップロード対応フォームを実装する

    React Hook Form の register でファイル入力を管理し、Zod でファイルサイズ・拡張子バリデーションを行う実装例。

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

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

  • Next.js 15 Server Actions でフォーム送信を実装する

    Next.js App Router の Server Actions を使ってフォーム送信を実装する例。useActionState と Zod によるサーバー側バリデーションを示す。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
  • LibraryZodTypeScript ファーストのスキーマバリデーションライブラリ。型推論と実行時バリデーションを同一スキーマで行える。
←サンプル一覧に戻る