概要
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 の message | errors.<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) — 完成例と外部デモ