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

目次