🧐

Zod の refine/superRefine が常に実行されない問題に向き合ってみた

に公開

Zod において相関バリデーションを実装する場合、一般的にはrefine/superRefineを使うことになります。
相関バリデーションとは他のフィールドの入力値を用いて行われるバリデーションのことです。
素直な書き方は次のようになります。

一般的な相関バリデーション実装例
const formSchema = z
  .object({
    email: z.email("有効なメールアドレスを入力してください。"),
    password: z.string().min(8, "パスワードは8文字以上で入力してください。"),
    passwordConfirmation: z.string().min(1, "確認用パスワードを入力してください。"),
  })
  .superRefine((values, ctx) => {
    if (values.password !== values.passwordConfirmation) {
      ctx.addIssue({
        code: "custom",
        path: ["passwordConfirmation"],
        message: "パスワードが一致しません。",
      });
    }
  });

これは実際ちゃんと動きます。バリデーションモードが onBlur である場合は、「確認用パスワード」に「パスワード」と違う文字列を入力すると、フォーカスを外した瞬間に「パスワードが一致しません。」と表示されます。

しかしある条件を満たすとこのsuperRefineが実行されないケースが存在します。

どういう場合に実行されないのか

結論から言うと、大元の z.object 内のいずれかのフィールドで invalid_typeなエラーが発生すると、それに続く refine/superRefine は実行されなくなります。

試してみましょう。例えば次のようなスキーマ定義に変更してみます。

const formSchema = z
  .object({
    plan: z.number().int().min(1, "プランを選択してください。").optional(),
    email: z.email("有効なメールアドレスを入力してください。"),
    password: z.string().min(8, "パスワードは8文字以上で入力してください。"),
    passwordConfirmation: z.string().min(1, "確認用パスワードを入力してください。"),
  })
  .superRefine((values, ctx) => {
    if (values.password !== values.passwordConfirmation) {
      ctx.addIssue({
        code: "custom",
        path: ["passwordConfirmation"],
        message: "パスワードが一致しません。",
      });
    }
  });

planフィールドを追加しました。これは任意選択な select 要素を想定しています。

想定している select 要素
<label className="flex flex-col gap-2 text-sm text-slate-700">
  利用プラン
  <select
    className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-base text-slate-900 outline-none ring-offset-2 focus:border-slate-400 focus:ring-2 focus:ring-slate-200"
    {...register("plan", { valueAsNumber: true })}
    aria-invalid={Boolean(errors.plan)}
  >
    <option value="">選択してください</option>
    <option value="1">ベーシック</option>
    <option value="2">プロ</option>
    <option value="3">エンタープライズ</option>
  </select>
  {errors.plan?.message && (
    <span className="text-xs text-rose-600">{errors.plan.message}</span>
  )}
</label>

この状態で画面から操作してみます。利用プランは変更せずに、パスワードをあえて確認用と一致しないように入力し、そのまま送信ボタンを押します。

このようになりました。パスワード(確認)フィールドにエラーが表示されていない一方で、利用プランの方にはinvalid_typeなエラーが発生しています。
未選択時は空文字でvalueAsNumberにより NaN になるので、まあ当然と言えば当然の挙動かなとは思います。
上記はあくまでも一例で、他にもinvalid_typeなエラーは往々にして起こります。例えば date や file なんかもそうです。

しかしこの問題、結構困りませんか…?
ちゃんと入力値の型指定をしてそもそもinvalid_typeなエラーを出さなければいいと言われればそこまでですが、それをスキーマに丁寧に書くとすごく冗長な記述になります。ちょっと極端な例かもしれませんが次のような記述をする必要が出てきます。

const planSchema = z.string()
    .nullish()
    .transform((v) => v ?? "")
    .pipe(z.string().min(1, "プランを選択してください。"))
    .transform((v) => Number(v))
    .pipe(z.number().int().min(1, "プランを選択してください。"));

const formSchema = z.object({
  plan: planSchema,
...

これは今とっさに AI に書いてもらったコードなので動く保証はないですが、こういうz.transformz.preprocessで入力は string で受けつつも最終的には number に変換してからバリデーションするみたいなことをする必要が出てきます。
さらに React Hook Form のようなフォームライブラリと組み合わせるとまた調整が複雑になっていきます。

このように可読性が下がってしまうくらいなら、ある程度はinvalid_typeエラーは許容して、「正しく入力してください」のような汎用的なエラーメッセージを出すようにしようという落とし所になるのも割と自然な話なのかなと思います。

この問題自体は以前からずっとあった

以前(筆者が知る限りバージョンが 3 系の頃)からこの問題自体はいくつも提起されていました。例えばこの issue がそのひとつです。

https://github.com/colinhacks/zod/issues/479

少し余談にはなりますが、この問題に筆者が気付いた頃は、まだ筆者がフロントエンド初学者だったのもあり、この問題への有効な対策ができずに結局 Zod はやめて Yup に移行したことがあります。Yup は型安全性こそ Zod より劣りますが、まあこの問題でいつまでも苦しむくらいなら…という断腸の思いで移行を決断しました。
それからも長いこと望まれていたのになかなか解決されない問題だったようですが、最近個人的に再度この問題に向き合ってみたところ、どうやらバージョン 4 系からこの問題への解決策が提供され始めたっぽいと知り、この記事を書いている次第です。

解決策

前述の通り Zod のバージョンが 4 系になってから、一応解決できるようになりました。「一応」と書いたのは、依然としてデフォルトではinvalid_typeなエラーが発生すると止まるからです。ただ以下の新たに追加されたパラメーターを適用することで、これを回避して常に相関バリデーションが実行されるようにすることができます。

refinewhenパラメーター

新たにrefine関数にwhenパラメーターが追加されました。次のように適用します。

const formSchema = z.object({
  plan: z.number().int().min(1, "プランを選択してください。").optional(),
  email: z.email("有効なメールアドレスを入力してください。"),
  password: z.string().min(8, "パスワードは8文字以上で入力してください。"),
  passwordConfirmation: z.string().min(1, "確認用パスワードを入力してください。"),
}).refine(
    data => data.password === data.passwordConfirmation,
    {
      message: "パスワードが一致しません。",
      path: ['passwordConfirmation'],
      when: () => true
    }
);

whenパラメーターは このrefineを実行するかどうか を指定することができます。true なら実行します。

上手く動きましたね。

Zod の内部実装ではどういう判定がされているか追っていきましょう。
該当部分はこうなっています。
https://github.com/colinhacks/zod/blob/v4.3.6/packages/zod/src/v4/core/schemas.ts#L218-L227

  • when が指定されている かつ それが false ならそのチェックをスキップする
  • when が指定されていない かつ isAborted = true ならそのチェックをスキップする

そしてisAbortedの実装はこうなっています。
https://github.com/colinhacks/zod/blob/v4.3.6/packages/zod/src/v4/core/util.ts#L804-L812

つまりz.object内で発生した issues の中でひとつでも継続不可だと判断されれば、その時点で以降のチェックはスキップされることでrefine/superRefineは実行されないということです。
なお、invalid_type以外の issue は基本的に continue: !def.abort が付与されるため、特別指定しない限りは常にcontinue: trueとなり、エラーが発生しても以降のチェックは止まりません。一方で、invalid_typeはそもそもcontinueを持たないため、中断扱いになります。

superRefineの場合

ではsuperRefineの場合はどうするのがいいのでしょうか。残念ながら、上記のwhenパラメーターのようなものはsuperRefineには無いようです。
しかしrefineではなくsuperRefineで書きたいときもありますよね。複数の条件を連続で書きたいとかそういうとき。

なんとか上手いやり方はないかと探していたところ、先程の GitHub の issue であるコメントが目に入りました。曰く次のようなラッパー関数で囲うといいよとのことです。なるほど!

function postprocess<T extends z.ZodType> (
  baseSchema: T,
  callback: z.core.CheckFn<z.core.output<T>>,
  when: z.core.$ZodCheckDef['when'] = () => true,
) {
  const newCheck = z.check(callback);
  newCheck._zod.def.when = when;
  return baseSchema.check(newCheck);
}

適用するとこうなります。

const formSchema = postprocess(
    z.object({
      plan: z.number().int().min(1, "プランを選択してください。").optional(),
      email: z.email("有効なメールアドレスを入力してください。"),
      password: z.string().min(8, "パスワードは8文字以上で入力してください。"),
      passwordConfirmation: z.string().min(1, "確認用パスワードを入力してください。"),
    }),
    (ctx) => {
      if (ctx.value.password !== ctx.value.passwordConfirmation) {
        ctx.issues.push({
          code: "custom",
          message: "パスワードが一致しません。",
          input: ctx.value.passwordConfirmation,
          path: ['passwordConfirmation'],
        });
      }
      // 続けて他のバリデーションも書ける
    }
);

画面の結果はrefineと全く同じなので割愛しますが、確かに上手く動いてくれます。
つまりこれを通せばそもそもsuperRefineを書く必要すらないということですね(笑)。

注意点としてはsuperRefineにおけるctx.addIssueといった書き方はできないので、issues 配列に直接 push する形をとる必要があります。その際、input 属性の指定が必須で、入力された値を渡す必要があります。
z.checkもバージョン 4 系から追加された関数です。公式ドキュメントにも説明がありますが、やれることはsuperRefineと同じだけども、より低レベルな API であり、内部寄りな書き方になるので冗長かつ複雑になりやすいらしいです。

つまり上記のpostprocess関数は、

  1. 相関バリデーション用のチェック(第二引数)を作る
  2. 中断状態でも必ず走るようにwhenを付ける
    • 「いつ実行するか」の条件を上書きする
    • 通常はwhenが無いので継続不可ならスキップされるが、ここでwhenを指定すると true なら実行されるようになる
    • デフォルトは true を返すので常に実行される
  3. それをベーススキーマに追加する

といった処理の流れになります。

なお、この 2 において_zod.defに直接アクセスして書き換えている点に注意は必要そうです。
将来変更の可能性がだいぶ高いので、腐敗防止層的に共通化して後から変更が容易にできるようにしておくのが吉だと思われます。

おわりに

まだ(特にsuperRefineにおいては)無理矢理感も否めないんですが、一旦はwhencheckで回避できるので、前みたいに諦めなくてよくなったのは個人的にはとても嬉しいです。
こういう形ではありますけど、一応公式な解決策が用意されたのはよかったです。今後もう少しスッと書ける手段が増えたら最高ですね。
Valibot はどうなんだろうなー。

Discussion