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

parseとsafeParseの違い — 例外で落とすか結果で受けるか

約8分
この章の目次開く

スキーマを書いただけでは何も起きません。実際に値を通すには parse か safeParse を呼びます。同じ検証をしますが、失敗したときの伝え方がまったく違います。

学習者学習者

2つあるってことは使い分けがあるんだよね? 名前的に safe の方が安全そうだけど、じゃあ parse はいつ使うの?

parse — 失敗すると例外を投げる

構文: schema.parse(data)

引数渡せるもの説明
data何でも(unknown)検証したい値

戻り値: 検証を通った値。型はスキーマから推論された型になる。失敗した場合は戻り値がなく、ZodError が throw される

z.string().parse('hello'); // "hello"
z.string().parse(123); // ZodError が throw される
ts

成功時のコードが素直に書けるのが利点です。戻り値をそのまま使えて、分岐が要りません。裏を返すと、失敗を処理したいなら try / catch で囲む必要があります。

try {
  const name = z.string().parse(input);
  console.log(name);
} catch (e) {
  if (e instanceof z.ZodError) {
    console.log(e.issues); // 何が不正だったかの配列
  }
  throw e; // Zod以外の例外は握りつぶさず投げ直す
}
ts

safeParse — 結果をオブジェクトで返す

構文: schema.safeParse(data)

戻り値: 例外を投げず、結果オブジェクトを返す

結果中身
成功{ success: true, data: 検証済みの値 }
失敗{ success: false, error: ZodError }
const result = z.string().safeParse(input);
 
if (!result.success) {
  // ここでは result.error が使える
  return { message: '入力が不正です', issues: result.error.issues };
}
 
// ここから先、result.data は string として扱える
console.log(result.data.toUpperCase());
ts

success の値によって中身が変わる判別可能なユニオンになっているため、if (!result.success) で分岐すると、それ以降でTypeScriptが result.data の型を正しく絞り込みます。この仕組みについては TypeScriptの絞り込み(narrowing)の章が詳しいです。

成功と失敗で持っているプロパティが違うので、分岐する前に result.data を触ることはできません。型の側から順番を強制されます。

4つのメソッド

非同期版を含めると4つあります。

メソッド戻り値失敗時
.parse(data)検証済みの値ZodError を throw
.safeParse(data){ success, data } または { success, error }例外を投げない
.parseAsync(data)検証済みの値の PromisePromiseが reject される
.safeParseAsync(data)結果オブジェクトの Promise例外を投げない

.safeParseAsync() には .spa() という短い別名もあります。

どちらを使うか

判断の軸は「その失敗をユーザーに伝えるのか、プログラムを止めるのか」です。

場面使うもの理由
フォームの入力値safeParseどの項目がなぜ不正かを画面に返す必要がある
APIのリクエストボディsafeParse400と一緒にエラー内容を返す
APIレスポンスの検証safeParse落とさずリトライや代替表示に切り替えられる
起動時の環境変数parse設定が壊れているなら起動させない方が安全
内部で作った値の念のための検証parse失敗=プログラムのバグなので例外でよい
先生先生

迷ったら safeParse でいい。外から来たデータで例外を投げると、呼び出し側が try を書き忘れた瞬間にアプリごと落ちるからね。逆に環境変数みたいな「壊れてたら動かしちゃいけない」ものは、あえて parse で落とすのが正解になる。

分かれ道で選択する人のイラスト

非同期版が必要になるのは

refine に非同期の関数(DBへの重複チェックなど)を渡した場合です。この場合、同期の parse を呼ぶとエラーになります。

const schema = z.string().refine(async (v) => await isUnique(v));
 
schema.parse('abc');
// $ZodAsyncError: Encountered Promise during synchronous parse. Use .parseAsync() instead.
ts

エラーメッセージが「.parseAsync() を使え」と教えてくれるので、出たら差し替えれば済みます。非同期の検証が必要になるのは、refine に async 関数を渡す場合だけです。同期の検証しか書いていないなら parse / safeParse のままで構いません。

戻り値を使わないと変換が反映されない

見落としやすい落とし穴です。parse と safeParse は、検証を通った値を新しく作って返します。元の変数は書き換わりません。

const input = { name: '  Alice  ' };
const schema = z.object({ name: z.string().trim() });
 
const result = schema.parse(input);
 
result.name; // "Alice" — trim済み
input.name; // "  Alice  " — 元のまま
ts

オブジェクトの場合、返ってくるのは別のオブジェクトです(同じ参照ではありません)。.trim() や .default() を書いたのに効いていないように見えるときは、元の変数を使い続けていないかを確認してください。

検証したあとは、必ず戻り値(result.data)の方を使います。

よくあるハマりどころ

safeParse の結果をそのまま使おうとする

safeParse が返すのは検証済みデータそのものではなく、success を持つ結果オブジェクトです。result.data を取り出す必要があります。分岐を書かずに result.data へ触ろうとすると、TypeScriptがエラーで教えてくれます。

エラーをそのままユーザーに表示する

ZodError のメッセージは既定では英語で、Invalid input: expected string, received undefined のような開発者向けの文言です。そのまま画面に出さず、項目ごとのメッセージに整形してください。z.treeifyError(error) を使うと項目名をキーにしたオブジェクトが得られ、.min(1, "1文字以上で入力してください") のように各メソッドの第2引数で日本語のメッセージを指定できます。

失敗したときだけログを出して処理を続ける

safeParse は例外を投げないため、失敗しても書き忘れると処理が続きます。if (!result.success) の中で必ず return するか、エラーを投げるかを決めてください。「安全」なのは例外が飛ばないことであって、不正なデータが安全になるわけではありません。

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

  • parse は失敗時に ZodError を throw、safeParse は結果オブジェクトを返す
  • 外から来たデータは safeParse、起動時の設定は parse が基本
  • try / catch で受けるときは e instanceof z.ZodError で判定する
  • 非同期の refine を使うなら parseAsync / safeParseAsync
  • 検証後は元の変数ではなく戻り値を使う。変換は戻り値にだけ反映される

次の章では、ここまで何度か顔を出した「空文字は通るのか」「null と undefined はどちらが弾かれるのか」を デフォルト挙動の早見表 としてまとめます。迷ったときに開く章です。

参考リンク

TypeScriptクイズに挑戦するこの章で学んだ型と検証の知識を、4択クイズでアウトプットして定着させよう