union・discriminatedUnion・enumの使い分け
この章の目次開く
「ステータスは draft か published のどちらか」「レスポンスは成功形か失敗形のどちらか」——選択肢を表すスキーマの書き方です。似たものが4つあるので、使い分けを先に示します。
| やりたいこと | 使うもの |
|---|---|
| 特定の値ちょうど1つに限定する | z.literal() |
| 決まった値の集合に限定する | z.enum() |
| 型の違うスキーマのどれか | z.union() |
| 目印のキーで形が切り替わるオブジェクト | z.discriminatedUnion() |
z.literal — 値そのものを固定する
構文: z.literal(値)
戻り値: その値ちょうどしか通さないスキーマ
z.literal('a').safeParse('b');
// エラー: Invalid input: expected "a"配列を渡すと、複数の値のどれかを許せます。
z.literal(['a', 'b']).safeParse('b').success; // true単体で使うことは少なく、次に説明する discriminatedUnion の目印として使うのが主な役割です。
z.enum — 決まった選択肢に限定する
構文: z.enum(値の配列)
戻り値: 配列に含まれる値だけを通すスキーマ
const status = z.enum(['draft', 'published', 'archived']);
status.safeParse('green');
// エラー: Invalid option: expected one of "draft"|"published"|"archived".options で選択肢の配列を取り出せます。セレクトボックスの選択肢をスキーマから生成すれば、画面と検証がずれません。
status.options; // ['draft', 'published', 'archived']TypeScriptの enum やオブジェクトを渡すこともできます。
const Role = { Admin: 'admin', User: 'user' } as const;
z.enum(Role).safeParse('admin').success; // true
z.union — どれか1つに当てはまればよい
構文: z.union([スキーマ1, スキーマ2, ...])
戻り値: どれか1つでも通れば成功するスキーマ
const idSchema = z.union([z.string(), z.number()]);
idSchema.safeParse(1).success; // true.or() を使っても同じです。z.string().or(z.number()) と書けます。
型が根本的に違うもの(文字列と数値など)を受けたいときに向きます。ただし、失敗したときのエラーが弱いという弱点があります。
z.union([z.string(), z.number()]).safeParse(true).error.issues[0];
// { code: 'invalid_union', message: 'Invalid input' }「どれにも当てはまらなかった」としか分かりません。オブジェクト同士の union だと、これがかなり困ります。
z.discriminatedUnion — 目印のキーで形を切り替える
構文: z.discriminatedUnion(目印のキー名, [スキーマの配列])
| 引数 | 説明 |
|---|---|
| 目印のキー名 | どのオブジェクトかを判別するキー(type など) |
| スキーマの配列 | 目印のキーを z.literal() で持つオブジェクトスキーマたち |
戻り値: 目印の値を見て、対応するスキーマだけで検証するスキーマ
const event = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('input'), value: z.string() }),
]);type の値を見て、それに対応するスキーマだけを適用します。これがエラーメッセージに効きます。
エラーの具体性がまるで違う
同じ不正データを、通常の union と discriminatedUnion に流した結果を比べます。
// 入力: { type: 'input', value: 123 } ← value が数値になっている| 使ったもの | 得られるエラー |
|---|---|
z.union() | invalid_union / Invalid input の1件だけ。どこが悪いか不明 |
z.discriminatedUnion() | path: ['value'] / expected string, received number |
目印のキーがあるオブジェクトの選択肢なら、迷わず discriminatedUnion を使ってください。ユーザーに返せるエラーになるかどうかが変わります。
目印の値そのものが未知の場合は、それ専用のエラーが出ます。
event.safeParse({ type: 'scroll' }).error.issues[0];
// { code: 'invalid_union', path: ['type'],
// message: "Invalid discriminator value. Expected 'click' | 'input'" }
学習者union でも動くのに、わざわざ discriminatedUnion にする意味ってそこだけ?
先生速度も違うよ。union は候補を片っ端から試すけど、discriminatedUnion は目印を見て1つに決め打ちする。選択肢が増えるほど差が出るし、何よりエラーが読める。オブジェクトの選択肢なら基本はこっちだね。
実務での典型例
APIレスポンスの成功・失敗を1つのスキーマで表す場合です。
const apiResult = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.object({ id: z.number() }) }),
z.object({ status: z.literal('error'), message: z.string() }),
]);
const result = apiResult.parse(json);
if (result.status === 'success') {
result.data.id; // ここでは data が存在する型になる
} else {
result.message; // ここでは message が存在する型になる
}検証を通した後、status で分岐するとTypeScript側も型を絞り込んでくれます。判別可能なユニオンの仕組みは TypeScriptの絞り込みの章で詳しく扱っています。
よくあるハマりどころ
unionのエラーをユーザーに出しても意味が通じない
前述のとおり Invalid input としか出ません。ユーザー向けのメッセージが必要なら、discriminatedUnion にするか、z.union() の第2引数でメッセージを指定します。
enumに数値を入れたい
z.enum() が扱うのは文字列です。数値の選択肢は z.literal([1, 2, 3]) か z.union([z.literal(1), z.literal(2)]) で表します。
選択肢を2か所に書いてしまう
セレクトボックスの選択肢と、検証用の z.enum() を別々に書くと、片方だけ更新されてずれます。.options を使ってスキーマを唯一の定義元にしてください。
ちゃんと使うためのポイント
- 値の限定は
z.literal()(1つ)とz.enum()(集合) -
目印キーを持つオブジェクトの選択肢は
discriminatedUnion。エラーが具体的になり、速度も有利 - 素の
z.union()のエラーはInvalid inputだけで、原因が読めない z.enum().optionsを画面の選択肢に使えば、検証とUIがずれない
ここまでは「形が正しいか」の検証でした。z.object や z.array と組み合わせれば、たいていのデータ構造は表現できます。
参考リンク
- Unions - Zod — union・discriminatedUnion・enumの公式リファレンス(英語)