投稿日:2026/8/15
更新日:2026/8/15

refine は「Zod の型チェックを通った値」に独自ルールを足すメソッド。基本形は単純だが、いつ実行され、いつスキップされるかを勘違いしていると、エラーメッセージが二重に出たり、逆に出なかったりする。v3 と v4 で挙動が変わった箇所もあるので、実際に動かして確認した結果をまとめる。
検証環境: Zod 4.4.3 / 3.25.76(Node.js)
refine は値を変えない。true を返せば OK、false なら 1 件のエラー。値を変えたいときは transformpath を指定しないとエラーがルートに付くinvalid_type)だけ refine はスキップされる。.min() などの制約違反では refine は走る ← 一番のハマりどころZodEffects が廃止され、.refine() の後も .extend() / .pick() が使えるようになった(v3 では使えない)superRefine は v4 でも非推奨ではない。.check() は置き換えではなく低レベル APIconst myString = z.string().refine((val) => val.length <= 10, {
error: "10文字以内で入力してください", // v3 では message
});
第 1 引数のコールバックが truthy を返せば通過、falsy ならエラー。メッセージだけなら第 2 引数に文字列を直接渡せる。
z.string().refine((val) => val.length <= 10, "10文字以内で入力してください");
オプション名がバージョンで違う。 v4 は
error、v3 はmessage。v4 でもmessageは動くが、公式ドキュメントはerrorに統一されている。
refine はあくまで検証であって、返り値は入力値のまま。
z.string().refine(() => "何か文字列を返す").safeParse("x");
// → { success: true, data: 'x' } ← 返した文字列は data にならない
pathrefine の存在意義はここにある。z.string().min() のような組み込みチェックでは、フィールドをまたぐ条件が書けない。
const passwordForm = z
.object({
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
error: "パスワードが一致しません",
path: ["confirm"], // ← これが重要
});
path を省略するとエラーの位置が**ルート(path: [])**になる。
// path なしで不一致のデータを渡した場合
[{ "path": [], "code": "custom", "message": "一致しません" }]
// path: ["confirm"] を指定した場合
[{ "path": ["confirm"], "code": "custom", "message": "一致しません" }]
React Hook Form のようにフィールド単位でエラーを表示する仕組みでは、path がないとどの入力欄に赤字を出せばいいか分からない。オブジェクトに対する refine では原則付ける。
ここが最も誤解しやすい。「前段のバリデーションが失敗したら refine は走らない」は誤り。
正しくは「継続不能(non-continuable)なエラーが出たときだけスキップされる」。実質的には型が違うときだけと考えてよい。
flowchart TD
Input["入力値"] --> TypeCheck{"型は正しいか"}
TypeCheck -->|"いいえ"| Skip["invalid_type エラーを返す<br/>refine はスキップされる"]
TypeCheck -->|"はい"| Checks["min / max などの<br/>組み込みチェックを順に実行"]
Checks --> Refine["失敗していても refine を実行"]
Refine --> Result["集まったエラーをすべて返す"]
実測で確認する。
const schema = z.string().min(8).refine((v) => v === v.toLowerCase(), "小文字のみ");
schema.safeParse("ABC");
"ABC" は 8 文字未満なので .min(8) に引っかかる。それでも refine は実行され、エラーが 2 件返る。
[
{ "path": [], "code": "too_small", "message": "Too small: expected string to have >=8 characters" },
{ "path": [], "code": "custom", "message": "小文字のみ" }
]
一方、型そのものが違う場合は refine のコールバックが呼ばれない。
z.string().refine((v) => v.length > 3, "短すぎ").safeParse(123);
// → invalid_type のみ。refine のコールバックは未実行
これは合理的で、refine のコールバックには string として渡す前提の処理が書かれているため、型が違う値を渡すと中で例外になってしまう。この挙動は v3 も v4 も同じだった。
abort: true で止める(v4 のみ)後続のチェックを走らせたくないなら abort を付ける。
const schema = z
.string()
.refine((val) => val.length > 8, { error: "短すぎ", abort: true })
.refine((val) => val === val.toLowerCase(), { error: "小文字のみ" });
schema.safeParse("ABC");
// → [{ "code": "custom", "message": "短すぎ" }] ← 2つ目は実行されない
v3 に abort はない。
3 の挙動は、オブジェクトに refine を付けたときに実害として現れる。
flowchart TD
Start["safeParse を実行"] --> Field["各フィールドを検証"]
Field --> Judge{"どんなエラーが出たか"}
Judge -->|"型が違う<br/>invalid_type"| SkipRefine["object の refine は実行されない"]
Judge -->|"制約違反<br/>too_small など"| RunRefine["object の refine が実行される"]
Judge -->|"エラーなし"| RunRefine
RunRefine --> Both["制約違反と refine のエラーが<br/>同時に返ることがある"]
const schema = z
.object({ password: z.string().min(8), confirm: z.string() })
.refine((d) => d.password === d.confirm, { error: "一致しません", path: ["confirm"] });
schema.safeParse({ password: "abc", confirm: "xyz" });
password が 8 文字未満なので too_small が出る。それに加えて refine も走り、「一致しません」も返る。
[
{ "path": ["password"], "code": "too_small", "message": "Too small: expected string to have >=8 characters" },
{ "path": ["confirm"], "code": "custom", "message": "一致しません" }
]
利用者から見ると「短すぎます」と「一致しません」が同時に出る。まだ入力途中なのに一致エラーを見せられるのは親切ではない。
when で実行条件を絞る(v4 のみ)when は「この refinement を実行してよいか」を判定する関数。基底スキーマが通ったときだけ走らせる。
const baseObj = z.object({ password: z.string().min(8), confirm: z.string() });
const schema = baseObj.refine((d) => d.password === d.confirm, {
error: "一致しません",
path: ["confirm"],
when: (payload) => baseObj.safeParse(payload.value).success, // ← 追加
});
結果、エラーが段階的になる。
| 入力 | when なし |
when あり |
|---|---|---|
password: "abc", confirm: "xyz" |
too_small + 一致しません |
too_small のみ |
password: "abcdefgh", confirm: "xyz" |
一致しません | 一致しません |
v3 に when はないため、v3 では「フィールド単位のスキーマを先に safeParse して、通ったら全体を検証する」ように呼び出し側を二段構えにするしかない。
ZodEffects の廃止v3 では .refine() を付けるとスキーマが ZodEffects にラップされ、元のクラスのメソッドが全部消えた。
// v3 (3.25.76)
const schema = z.object({ password: z.string() }).refine((d) => true);
schema.constructor.name; // → "ZodEffects"
typeof schema.extend; // → "undefined" ❌ 使えない
typeof schema.pick; // → "undefined" ❌ 使えない
このため v3 では「refine はスキーマ組み立ての最後に付ける」のが鉄則だった。
v4 では refinement がスキーマ内部の checks 配列として保持されるようになり、クラスが変わらない。
// v4 (4.4.3)
const schema = z.object({ password: z.string() }).refine((d) => true);
schema.constructor.name; // → "ZodObject" ✅ 変わらない
typeof schema.extend; // → "function" ✅ 使える
さらに .extend() した後も refinement は引き継がれる(実測)。
const base = z
.object({ password: z.string().min(8), confirm: z.string() })
.refine((d) => d.password === d.confirm, { error: "一致しません", path: ["confirm"] });
const extended = base.extend({ email: z.string() });
extended.safeParse({ password: "abcdefgh", confirm: "different", email: "a@example.com" });
// → [{ "path": ["confirm"], "code": "custom", "message": "一致しません" }]
文字列スキーマも同様で、refine の後に文字列メソッドを続けられる(TypeScript の型チェックも通る)。
const chained = z.string().refine((v) => v.length > 3).min(8).toLowerCase(); // ✅ v4 のみ
transformは今も別。 v4 でも.transform()はZodPipeを返すため、その後.extend()などは使えない。「refine は元のクラスのまま、transform は変わる」と覚える。
// v4 での戻り値クラス
z.string() // ZodString
z.string().refine(() => true) // ZodString ← 変わらない
z.string().superRefine(() => {}) // ZodString ← 変わらない
z.string().transform((v) => v.length) // ZodPipe ← 変わる
superRefine と .check()エラーを複数出したい、エラーコードを指定したい場合は superRefine。
const password = z.string().superRefine((val, ctx) => {
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: "custom", message: "大文字が必要", input: val });
}
if (!/[0-9]/.test(val)) {
ctx.addIssue({ code: "custom", message: "数字が必要", input: val });
}
});
password.safeParse("abc");
// → 「大文字が必要」「数字が必要」の 2 件
refine は 1 回の呼び出しにつきエラー 1 件しか作れないので、要件を個別に伝えたいときは superRefine を選ぶ。
.check() は superRefine の置き換えではないv4 のドキュメントには .check() という低レベル API があるが、superRefine は非推奨になっていない。公式は .check() を「より低レベルで、一般に .superRefine() より複雑。パフォーマンスが重要な経路では速いが、記述は冗長」と位置付けている。
// .check() は ctx.issues に直接 push する
const schema = z.string().check((ctx) => {
if (ctx.value.length < 3) {
ctx.issues.push({ code: "custom", message: "3文字以上", input: ctx.value });
}
});
通常は superRefine のままでよい。
ctx.path は v4 で消えたv3 では ctx.path で現在位置を取れたが、v4 では取れない(新しいパース機構が path を遅延評価するため)。
// v3: ctx.path → [] ctx から path が読める
// v4: ctx.path → undefined ctx のキーは value / issues / addIssue のみ
v3 で ctx.path を使ってエラー位置を組み立てていたコードは、v4 移行時に addIssue の path オプションを明示する形へ書き換えが必要。
コールバックが Promise を返す場合、parseAsync / safeParseAsync を使う。
const userId = z.string().refine(async (id) => {
return await userExists(id); // DB や API に問い合わせる
}, "存在しないユーザーです");
await userId.parseAsync(input); // ✅
同期の parse を呼ぶと、検証が失敗するのではなく例外が飛ぶ。
userId.parse("abcdef");
// ❌ $ZodAsyncError: Encountered Promise during synchronous parse. Use .parseAsync() instead.
safeParse でも同じ例外になる(success: false では返ってこない)ので、非同期 refinement を 1 つでも含むスキーマは、呼び出し側をすべて非同期に統一する必要がある。
| 項目 | v3 (3.25.76) | v4 (4.4.3) |
|---|---|---|
.refine() の戻り値クラス |
ZodEffects |
元のクラスのまま |
refine 後の .extend() / .pick() |
❌ 使えない | ✅ 使える(refinement も引き継ぐ) |
.transform() の戻り値 |
ZodEffects |
ZodPipe(メソッドは消える) |
型不一致(invalid_type)時の refine |
スキップ | スキップ |
制約違反(too_small など)時の refine |
実行される | 実行される |
abort オプション |
❌ なし | ✅ あり |
when オプション |
❌ なし | ✅ あり |
ctx.path |
取れる | ❌ undefined |
superRefine |
あり | あり(非推奨ではない) |
| メッセージ指定 | message |
error(message も動く) |
覚えておくこと。
path を書く。 オブジェクトの refine で省略するとエラーがルートに付くwhen、後続を止めたいなら abort.extend() で派生させられる