Zod discriminatedUnionの使い方 ― 判別キーでスキーマを自動で切り替える

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

処理の流れをイメージにすると、こうなる。

discriminatedUnionの振り分けフロー データ受信 typeの値を確認 email用スキーマ sms用スキーマ push用スキーマ to, subject, bodyを検証 phoneNumber, messageを検証 deviceToken, title, bodyを検証 どれにも一致しない場合 → discriminatedUnionのエラー

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() }),
])

違いを整理するとこうなる。

観点uniondiscriminatedUnion
スキーマの選び方先頭から順に全部試す判別キーの値で即座に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ではない」という矛盾した状況だけで、これは原理的に発生しない。

参考:GitHub Issue ― V4 discriminated union parse error does not inform which discriminator value is missing

カスタマイズすべき場所

エラーメッセージを設定するなら、以下の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.tostring だと推論される。しかし .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() を選べばいい。

目次