Zodでバリデーションを書いていると、「あるフィールドの値によって、必要なパラメータが変わる」というケースに遭遇する。たとえばフォームの種別、通知のタイプ、APIのアクション指定など、1つのエンドポイントやスキーマで複数のパターンを受け付けたい場面だ。
これをif文やswitch文でゴリゴリ分岐させると、コードの見通しが悪くなるし、型の恩恵も受けにくい。discriminatedUnionを使えば、スキーマ定義だけで分岐と型の絞り込みを同時に実現できる。
この記事ではdiscriminatedUnionの基本から、unionとの違い、エラーメッセージの注意点まで一通り整理する。
Zod v4についての補足 Zod v4では
z.union()が判別キーを自動検出するようになり、z.discriminatedUnion()は必須ではなくなった。ただしAPIとしては引き続き使用可能で、明示的に判別キーを指定したい場合には有効だ。本記事の概念や注意点はv3・v4どちらにも当てはまる。
参考:Zod v4 リリースノート
discriminatedUnionとは
discriminatedUnionは、共通のフィールド(判別キー)の値を見て、どのスキーマを適用するか自動で振り分ける機能だ。
たとえば通知システムを考えてみる。通知にはメール・SMS・プッシュ通知の3種類があり、それぞれ必要なデータが異なる。
import { z } from 'zod'
const notificationSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('email'),
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
z.object({
type: z.literal('sms'),
phoneNumber: z.string(),
message: z.string().max(160),
}),
z.object({
type: z.literal('push'),
deviceToken: z.string(),
title: z.string(),
body: z.string(),
}),
])第1引数の'type'が判別キーだ。各スキーマはtypeフィールドにz.literal()で固有の値を持っている。Zodはリクエストのtypeの値を見て、対応するスキーマを1つ選び、そのスキーマでバリデーションを実行する。
参考:Zod公式ドキュメント ― Discriminated Unions
処理の流れをイメージにすると、こうなる。
typeが'email'・'sms'・'push'のどれでもなければ、個々のスキーマに入る前の振り分け段階でエラーになる。
unionとの違い
Zodには似た機能としてz.unionもある。どちらも「複数のスキーマのどれかに一致すればOK」という点は同じだが、内部の動きが大きく異なる。
// union: 全パターンを順番に試す
const unionSchema = z.union([
z.object({ type: z.literal('email'), to: z.string().email() }),
z.object({ type: z.literal('sms'), phoneNumber: z.string() }),
])
// discriminatedUnion: 判別キーで即座に1つ選ぶ
const discSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), to: z.string().email() }),
z.object({ type: z.literal('sms'), phoneNumber: z.string() }),
])違いを整理するとこうなる。
| 観点 | union | discriminatedUnion |
|---|---|---|
| スキーマの選び方 | 先頭から順に全部試す | 判別キーの値で即座に1つ選ぶ |
| パフォーマンス | パターンが多いほど遅くなりうる | 常にO(1)で選択 |
| エラーメッセージ | 全パターンのエラーが混ざって分かりにくい | 「判別キーの値が不正」と明確に出る |
| 使える条件 | 制限なし | 共通の判別キーが必要 |
unionは全パターンを試した結果として「どれにも一致しなかった」という複合的なエラーを返す。パターンが増えるとエラーメッセージが読みにくくなるし、「toが足りない」のか「typeが間違っている」のかが判別しづらい。
discriminatedUnionは判別キーだけ見て振り分けるので、「typeの値が不正」なのか「typeは合っているがフィールドに不備がある」のかが明確に分かれる。判別キーとなるフィールドが存在するなら、discriminatedUnionを選んだ方がいい。
Zod v4での変化 Zod v4では
z.union()が内部で判別キーを自動検出し、discriminatedUnionと同等の最適化を行うようになった。つまり上記の表のパフォーマンス差はv4では解消されている。ただしz.discriminatedUnion()を使うことで「このスキーマは判別キーで分岐している」という意図をコード上で明示できるメリットは残る。 参考:Zod v4 リリースノート ― Smart unions
型の絞り込みが自動で効く
discriminatedUnionのもう一つの大きなメリットは、TypeScriptの型推論と連携する点だ。
type Notification = z.infer<typeof notificationSchema>
function handleNotification(data: Notification) {
switch (data.type) {
case 'email':
// data.to, data.subject, data.body にアクセスできる
// data.phoneNumber はコンパイルエラー
sendEmail(data.to, data.subject, data.body)
break
case 'sms':
// data.phoneNumber, data.message にアクセスできる
// data.to はコンパイルエラー
sendSms(data.phoneNumber, data.message)
break
case 'push':
// data.deviceToken, data.title, data.body にアクセスできる
sendPush(data.deviceToken, data.title, data.body)
break
}
}data.typeで分岐すると、各ブランチ内で型が自動的に絞り込まれる。存在しないフィールドにアクセスしようとするとコンパイルエラーになるため、分岐の書き漏れや誤ったフィールド参照を防げる。
これをunionで実現しようとすると、自前でtype guardを書く必要が出てくる。
参考:TypeScript Handbook ― Discriminated Unions
エラーメッセージの落とし穴
discriminatedUnionを使うとき、エラーメッセージの設定場所で混乱しやすいポイントがある。各スキーマのz.literal()にカスタムメッセージを設定したくなるが、実はこれは意味がない。
// これは設定しても使われない
z.object({
type: z.literal('email', {
errorMap: () => ({ message: 'typeには "email" を指定してください' }),
}),
// ...
})理由は処理順序にある。discriminatedUnionはまず判別キーの値で振り分け先を決める。typeが'email'なら「email用スキーマ」の中に入って各フィールドを検証するが、typeがどのliteralにも一致しなければ個々のスキーマには入らず、discriminatedUnion自体のエラーが発生する。
つまりz.literal('email')のエラーが出るのは「email用スキーマが選ばれたのにtypeがemailではない」という矛盾した状況だけで、これは原理的に発生しない。
カスタマイズすべき場所
エラーメッセージを設定するなら、以下の2箇所が適切だ。
discriminatedUnion自体のエラー ― 判別キーがどれにも一致しない場合。
const schema = z.discriminatedUnion('type', [
// ...各スキーマ
], {
errorMap: () => ({
message: 'typeにはemail・sms・pushのいずれかを指定してください',
}),
})各スキーマ内のフィールド ― 振り分け後のバリデーションエラー。
z.object({
type: z.literal('sms'),
phoneNumber: z.string({
required_error: '電話番号は必須です',
}),
message: z.string().max(160, 'メッセージは160文字以内にしてください'),
})この2箇所はどちらも実際にユーザーに届くエラーメッセージなので、カスタマイズする価値がある。
参考:Zod公式ドキュメント ― Error customization
discriminatedUnionを選ぶ判断基準
discriminatedUnionを使うかどうか迷ったら、「共通の判別フィールドがあるか」だけ考えればいい。
向いているのは、特定のフィールドの値で必要なパラメータが明確に分かれるケースだ。APIのアクション分岐、イベントの種別分岐、フォームのステップ分岐など、「このフィールドがこの値なら、このデータ構造」と宣言的に書ける場面で力を発揮する。
逆に向いていないのは、判別キーとなるフィールドが存在しないケースだ。「フィールドAがあればパターン1、フィールドBがあればパターン2」のように、フィールドの有無で分岐する場合はz.unionを使う。
判別キーがあるならdiscriminatedUnion。なければunion。実装で迷う場面があったら、まず「switch文のcase値になるフィールドがあるかどうか」を考えてみると判断しやすい。
.refine() で代替するパターンとの比較
discriminatedUnion の代わりに、全フィールドを1つの z.object() にまとめて .refine() でクロスフィールドバリデーションを書くアプローチもある。
const schema = z.object({
type: z.enum(['email', 'sms']),
to: z.string().email().optional(),
phoneNumber: z.string().optional(),
message: z.string().optional(),
}).refine((data) => {
if (data.type === 'email') {
return data.to !== undefined
}
return true
}, {
message: 'type が email の場合、to は必須です',
path: ['to'],
}).refine((data) => {
if (data.type === 'sms') {
return data.phoneNumber !== undefined
}
return true
}, {
message: 'type が sms の場合、phoneNumber は必須です',
path: ['phoneNumber'],
})
.refine() のコールバックは true を返せばバリデーションOK、false を返せばエラーになる。return true は「この条件に該当しないケースはスキップする」という意味だ。path オプションを指定すると、エラーが特定のフィールドに紐づくため、フォームライブラリと連携するときにエラー表示先を制御できる。
このアプローチには使いどころがある。全フィールドが1箇所にまとまるので全体像を把握しやすいし、条件の追加も .refine() を足すだけで済む。ただし discriminatedUnion と比べると明確なトレードオフがある。
| 観点 | discriminatedUnion | .refine() |
|---|---|---|
| 型の絞り込み | switch で自動的に絞り込まれる | 全フィールドが optional のまま。型ガードが必要 |
| パフォーマンス | 判別キーで即座にスキーマを選択 | 全フィールドをパース後にチェック |
| OpenAPI 生成 | oneOf として出力される | 実行時バリデーションなのでスキーマに反映されない |
| 可読性 | パターンごとにスキーマが分かれる | 1つの z.object() に集約できる |
| 拡張性 | パターン追加はスキーマを1つ追加 | .refine() を1つ追加 |
型推論が効かなくなる問題
.refine() 版の最大のデメリットは、TypeScript の型推論が効かなくなることだ。
discriminatedUnion なら data.type === 'email' の時点で data.to が string だと推論される。しかし .refine() 版では全フィールドが optional のままなので、ランタイムでは検証済みでも TypeScript には伝わらない。
// discriminatedUnion 版 → 型が自動で絞り込まれる
if (data.type === 'email') {
sendEmail(data.to) // OK: to は string と推論される
}
// .refine() 版 → optional のまま
if (data.type === 'email') {
sendEmail(data.to) // エラー: string | undefined を string に割り当てられない
sendEmail(data.to!) // 非nullアサーションで回避
}
非nullアサーション(!)は「この値は undefined ではないと開発者が保証する」という宣言だ。.refine() で検証済みなので実行時に問題は起きないが、型の安全性はコンパイラではなく開発者の判断に委ねられる。
どちらを選ぶか
フォームバリデーションのようにクライアントサイドで使う場合は、.refine() のほうがシンプルで扱いやすいことが多い。一方、API のリクエストバリデーションで使う場合は、discriminatedUnion のほうが型安全性と OpenAPI ドキュメントの精度で有利だ。
判断基準はシンプルで、型の自動絞り込みが必要なら discriminatedUnion、クロスフィールドの柔軟なチェックが優先なら .refine() を選べばいい。