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値になるフィールドがあるかどうか」を考えてみると判断しやすい。