YASD-TECH
YASD TECH
# TypeScript

Zodのrefineの挙動とハマりどころ(v3・v4の差分)

投稿日:2026/8/15

更新日:2026/8/15

ttitleImage

Zod の refine の挙動とハマりどころ(v3・v4 の差分)

refine は「Zod の型チェックを通った値」に独自ルールを足すメソッド。基本形は単純だが、いつ実行され、いつスキップされるかを勘違いしていると、エラーメッセージが二重に出たり、逆に出なかったりする。v3 と v4 で挙動が変わった箇所もあるので、実際に動かして確認した結果をまとめる。

検証環境: Zod 4.4.3 / 3.25.76(Node.js)


結論

  • refine は値を変えないtrue を返せば OK、false なら 1 件のエラー。値を変えたいときは transform
  • 複数フィールドをまたぐ検証では path を指定しないとエラーがルートに付く
  • 型が違うとき(invalid_type)だけ refine はスキップされる。.min() などの制約違反では refine は走る ← 一番のハマりどころ
  • v4 で ZodEffects が廃止され、.refine() の後も .extend() / .pick() が使えるようになった(v3 では使えない)
  • superRefine は v4 でも非推奨ではない.check() は置き換えではなく低レベル API

1. 基本形

ts
const myString = z.string().refine((val) => val.length <= 10, {
  error: "10文字以内で入力してください", // v3 では message
});

第 1 引数のコールバックが truthy を返せば通過、falsy ならエラー。メッセージだけなら第 2 引数に文字列を直接渡せる。

ts
z.string().refine((val) => val.length <= 10, "10文字以内で入力してください");

オプション名がバージョンで違う。 v4 は error、v3 は message。v4 でも message は動くが、公式ドキュメントは error に統一されている。

refine はあくまで検証であって、返り値は入力値のまま

ts
z.string().refine(() => "何か文字列を返す").safeParse("x");
// → { success: true, data: 'x' }   ← 返した文字列は data にならない

2. 複数フィールドをまたぐ検証と path

refine の存在意義はここにある。z.string().min() のような組み込みチェックでは、フィールドをまたぐ条件が書けない。

ts
const passwordForm = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    error: "パスワードが一致しません",
    path: ["confirm"], // ← これが重要
  });

path を省略するとエラーの位置が**ルート(path: [])**になる。

ts
// path なしで不一致のデータを渡した場合
[{ "path": [], "code": "custom", "message": "一致しません" }]

// path: ["confirm"] を指定した場合
[{ "path": ["confirm"], "code": "custom", "message": "一致しません" }]

React Hook Form のようにフィールド単位でエラーを表示する仕組みでは、path がないとどの入力欄に赤字を出せばいいか分からない。オブジェクトに対する refine では原則付ける。


3. いつ refine はスキップされるのか

ここが最も誤解しやすい。「前段のバリデーションが失敗したら refine は走らない」は誤り。

正しくは「継続不能(non-continuable)なエラーが出たときだけスキップされる」。実質的には型が違うときだけと考えてよい。

flowchart TD
    Input["入力値"] --> TypeCheck{"型は正しいか"}
    TypeCheck -->|"いいえ"| Skip["invalid_type エラーを返す<br/>refine はスキップされる"]
    TypeCheck -->|"はい"| Checks["min / max などの<br/>組み込みチェックを順に実行"]
    Checks --> Refine["失敗していても refine を実行"]
    Refine --> Result["集まったエラーをすべて返す"]

実測で確認する。

ts
const schema = z.string().min(8).refine((v) => v === v.toLowerCase(), "小文字のみ");
schema.safeParse("ABC");

"ABC" は 8 文字未満なので .min(8) に引っかかる。それでも refine は実行され、エラーが 2 件返る

json
[
  { "path": [], "code": "too_small", "message": "Too small: expected string to have >=8 characters" },
  { "path": [], "code": "custom",    "message": "小文字のみ" }
]

一方、型そのものが違う場合は refine のコールバックが呼ばれない

ts
z.string().refine((v) => v.length > 3, "短すぎ").safeParse(123);
// → invalid_type のみ。refine のコールバックは未実行

これは合理的で、refine のコールバックには string として渡す前提の処理が書かれているため、型が違う値を渡すと中で例外になってしまう。この挙動は v3 も v4 も同じだった。

abort: true で止める(v4 のみ)

後続のチェックを走らせたくないなら abort を付ける。

ts
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 はない。


4. object レベル refine の落とし穴

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/>同時に返ることがある"]
ts
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 も走り、「一致しません」も返る。

json
[
  { "path": ["password"], "code": "too_small", "message": "Too small: expected string to have >=8 characters" },
  { "path": ["confirm"],  "code": "custom",    "message": "一致しません" }
]

利用者から見ると「短すぎます」と「一致しません」が同時に出る。まだ入力途中なのに一致エラーを見せられるのは親切ではない。

対処:when で実行条件を絞る(v4 のみ)

when は「この refinement を実行してよいか」を判定する関数。基底スキーマが通ったときだけ走らせる。

ts
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 して、通ったら全体を検証する」ように呼び出し側を二段構えにするしかない。


5. v4 で変わったこと:ZodEffects の廃止

v3 では .refine() を付けるとスキーマが ZodEffects にラップされ、元のクラスのメソッドが全部消えた

ts
// 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 配列として保持されるようになり、クラスが変わらない

ts
// v4 (4.4.3)
const schema = z.object({ password: z.string() }).refine((d) => true);
schema.constructor.name; // → "ZodObject"   ✅ 変わらない
typeof schema.extend;    // → "function"    ✅ 使える

さらに .extend() した後も refinement は引き継がれる(実測)。

ts
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 の型チェックも通る)。

ts
const chained = z.string().refine((v) => v.length > 3).min(8).toLowerCase(); // ✅ v4 のみ

transform は今も別。 v4 でも .transform()ZodPipe を返すため、その後 .extend() などは使えない。「refine は元のクラスのまま、transform は変わる」と覚える。

ts
// v4 での戻り値クラス
z.string()                          // ZodString
z.string().refine(() => true)       // ZodString  ← 変わらない
z.string().superRefine(() => {})    // ZodString  ← 変わらない
z.string().transform((v) => v.length) // ZodPipe  ← 変わる

6. superRefine.check()

エラーを複数出したいエラーコードを指定したい場合は superRefine

ts
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() より複雑。パフォーマンスが重要な経路では速いが、記述は冗長」と位置付けている。

ts
// .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 を遅延評価するため)。

ts
// v3: ctx.path → []           ctx から path が読める
// v4: ctx.path → undefined    ctx のキーは value / issues / addIssue のみ

v3 で ctx.path を使ってエラー位置を組み立てていたコードは、v4 移行時に addIssuepath オプションを明示する形へ書き換えが必要。


7. 非同期 refine

コールバックが Promise を返す場合、parseAsync / safeParseAsync を使う

ts
const userId = z.string().refine(async (id) => {
  return await userExists(id); // DB や API に問い合わせる
}, "存在しないユーザーです");

await userId.parseAsync(input); // ✅

同期の parse を呼ぶと、検証が失敗するのではなく例外が飛ぶ

ts
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 errormessage も動く)

覚えておくこと。

  • path を書く。 オブジェクトの refine で省略するとエラーがルートに付く
  • 「前段が落ちたら refine は走らない」は嘘。 走らないのは型が違うときだけ。段階的にエラーを出したいなら v4 の when、後続を止めたいなら abort
  • v4 なら refine を最後に付ける制約から解放された。 共通スキーマに refine を付けて .extend() で派生させられる

参考

Index

  • Zod の refine の挙動とハマりどころ(v3・v4 の差分)
  • 結論
  • 1. 基本形
  • 2. 複数フィールドをまたぐ検証と path
  • 3. いつ refine はスキップされるのか
  • abort: true で止める(v4 のみ)
  • 4. object レベル refine の落とし穴
  • 対処:when で実行条件を絞る(v4 のみ)
  • 5. v4 で変わったこと:ZodEffects の廃止
  • 6. superRefine と .check()
  • .check() は superRefine の置き換えではない
  • ctx.path は v4 で消えた
  • 7. 非同期 refine
  • まとめ
  • 参考