本文へスキップ
ウェブエンジニア問題集
第9章

refineで独自ルール、transformで値を変換する

約7分
この章の目次開く

ここまでのスキーマは「文字列か」「10文字以内か」といった、値そのものの形を見るものでした。実務ではそれだけでは足りません。

  • パスワードと確認用パスワードが一致しているか
  • 開始日が終了日より前か
  • そのメールアドレスがまだ登録されていないか

こうした「複数の値の関係」や「外部に問い合わせないと分からないこと」は refine で書きます。


refine — 独自の条件を足す

構文: schema.refine(検証関数, メッセージまたはオプション)

引数渡せるもの説明
検証関数(値) => booleantrue を返せば合格、false で失敗
第2引数文字列 または オブジェクト失敗時のメッセージ。オブジェクトなら path も指定できる

戻り値: 条件を追加した新しいスキーマ

const password = z.string().min(8).refine((v) => /[0-9]/.test(v), '数字を1文字以上含めてください');
ts

エラーを特定の項目に紐づける

オブジェクト全体に refine を掛けると、エラーの path は空になります。フォームで「どの入力欄が悪いか」を示したいときは、path を指定します。

const form = z
  .object({ password: z.string(), confirm: z.string() })
  .refine((d) => d.password === d.confirm, {
    message: 'パスワードが一致しません',
    path: ['confirm'], // 確認用の入力欄にエラーを出す
  });
 
form.safeParse({ password: 'a', confirm: 'b' }).error.issues[0];
// { code: 'custom', path: ['confirm'], message: 'パスワードが一致しません' }
ts
path を指定しないと、フォームライブラリがどの欄にエラーを出せばよいか判断できません。

型が合わないときrefineは呼ばれない

地味に重要な性質です。基本の型検証に失敗した時点で、refine は実行されません。

z.object({ a: z.string() })
  .refine((d) => d.a.length > 3) // a が文字列でないときは呼ばれない
  .safeParse({ a: 123 });
ts

a が数値なので z.string() の段階で失敗し、refine は呼ばれません。d.a.length で例外が飛ぶ心配をしなくてよい、ということです。

学習者学習者

じゃあ refine の中では「型は正しい」前提で書いていいんだ。

先生先生

そのとおり。型チェックを通った値だけが渡ってくるから、中で typeof を確認し直す必要はないよ。


superRefine — エラーを複数出す

refine は失敗を1件しか返せません。「大文字が必要」「数字が必要」を同時に指摘したいときは superRefine を使います。

構文: schema.superRefine((値, ctx) => void)

const password = z.string().superRefine((v, ctx) => {
  if (!/[A-Z]/.test(v)) ctx.addIssue({ code: 'custom', message: '大文字を含めてください' });
  if (!/[0-9]/.test(v)) ctx.addIssue({ code: 'custom', message: '数字を含めてください' });
});
 
password.safeParse('abc').error.issues.map((i) => i.message);
// ['大文字を含めてください', '数字を含めてください']
ts

ctx.addIssue() を呼んだ回数だけエラーが増えます。呼ばなければ合格です。


非同期の検証

「このメールアドレスは登録済みか」のようにDBやAPIへ問い合わせる場合、refine に async 関数を渡します。

const email = z.email().refine(async (v) => !(await isTaken(v)), 'すでに登録されています');
 
await email.safeParseAsync('test@example.com');
ts

このスキーマを同期の parse で実行すると $ZodAsyncError になります(parseとsafeParseの章を参照)。非同期の検証を1つでも入れたら、呼び出し側も parseAsync / safeParseAsync に揃える必要があります。

打ち合わせをしている人たちのイラスト

transform — 検証しながら値を変える

構文: schema.transform(変換関数)

戻り値: 検証を通ったあと、変換関数の結果を返すスキーマ

z.string().transform((v) => v.length).parse('hello');
// 5 — 文字列を受け取って数値を返す
ts

transform は検証ではなく変換です。渡された値を弾く力はありません。検証したいなら、変換の前後で refine や別のスキーマを組み合わせます。

z.string()
  .transform((v) => v.length)
  .refine((n) => n > 3, '4文字以上にしてください');
ts

変換の順番は書いた順です。上の例では「文字列か確認 → 長さに変換 → 長さが4以上か確認」と流れます。

pipe — スキーマ同士をつなぐ

変換先をスキーマで表したいときは pipe が使えます。

z.string().pipe(z.coerce.number()).safeParse('42');
// { success: true, data: 42 }
ts

「まず文字列であることを確認し、次に数値へ変換して検証する」という流れを、スキーマの連結で表現できます。

入力の型と出力の型がずれる

transform を使うと、スキーマに入れる型と出てくる型が別物になります。

const schema = z.string().transform((v) => v.length);
// 入力: string / 出力: number
ts

この違いは z.input<typeof schema> と z.output<typeof schema> で取り出せます。z.infer は z.output と同じ、つまり変換後の型を返します。フォームの初期値の型が合わないと感じたときは、z.input の方を見てください。


よくあるハマりどころ

refineのメッセージを書き忘れる

Invalid input がそのままユーザーに出ます。refine とメッセージはセットです。

pathを指定せずフォームに繋ぐ

オブジェクト全体のエラーになるため、どの入力欄の下にも表示されません。「エラーは出ているのに画面に何も出ない」ときは path を疑ってください。

transformで値を弾こうとする

transform の中で undefined を返しても検証は成功扱いです。弾きたいなら refine を使うか、superRefine で ctx.addIssue() を呼びます。

非同期を混ぜたのに parse を呼んでいる

$ZodAsyncError が出ます。スキーマの奥深くに1つでも async の refine があれば、呼び出し側は非同期版に揃える必要があります。

ちゃんと使うためのポイント

  • 型で表せない条件(値の関係・外部への問い合わせ)は refine
  • refine にはメッセージを必ず添える。既定は Invalid input で役に立たない
  • フォームで使うなら path で対象の項目を指定する
  • 複数のエラーを同時に出したいなら superRefine と ctx.addIssue()
  • transform は変換であって検証ではない。弾く役目は refine に任せる
  • transform を入れると入力型と出力型がずれる。z.infer が返すのは出力型

ここまでで、形の検証・構造の組み立て・独自ルール・変換がひととおり揃いました。値が通るかどうかの基準に迷ったときは デフォルト挙動の早見表へ戻ってください。

参考リンク

  • Refinements - Zod — refine・superRefine・transformの公式リファレンス(英語)
TypeScriptクイズに挑戦するこの章で学んだ型と検証の知識を、4択クイズでアウトプットして定着させよう