アーキテクチャ

Discriminated Unionとは?判別可能なユニオン型の書き方とneverでの網羅チェック

Discriminated Unionとは?判別可能なユニオン型の書き方とneverでの網羅チェック

Discriminated Union(判別可能なユニオン型、タグ付きユニオン)は、複数のオブジェクト型をまとめたユニオン型のうち、すべてのメンバーが同じ名前のリテラル型プロパティを持つものを指します。このプロパティの値を比較するだけで、対応するメンバーへ型を絞り込めます(1つに確定させたいなら、同じ値を持つメンバーを2つ作らないように定義します)。ここでは成立条件、switchでの分岐、neverによる網羅性チェックまでを、TypeScript 7.0.2 で実際にコンパイルして得たエラー文言つきで整理します。ZodやPydanticでの実行時検証、Rustやprotobufでの相当機能にも触れます。

まとめ:Discriminated Unionの要点

  • 成立条件は「ユニオンの全メンバーが、共通の名前のプロパティをリテラル型で持つこと」。kind: string のように型が広がっていると絞り込みは効きません。
  • 判別プロパティを switch や if で比較すると、そのブロック内では対応するメンバーの固有プロパティにアクセスできます。
  • never 型の変数に代入する行を default 節に置くと、メンバーを追加したのに分岐を書き忘れたときにコンパイルエラー(TS2322)になります。
  • TypeScript 4.6以降では、共通プロパティをconstで分割代入するか、再代入されない引数として分割代入する場合に、判別プロパティと他のプロパティの型の対応が維持されます。
  • 実行時のJSONまで守るなら、Zodの z.discriminatedUnion やPydanticの discriminator を併用します。エラーメッセージの精度が通常のユニオンとはっきり変わります。

Discriminated Union(判別可能なユニオン型)の成立条件

TypeScript公式ハンドブックのNarrowingの章は、この型を次のように定義しています。

When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union, and can narrow out the members of the union.

共通プロパティが「リテラル型」であることが条件です。この共通プロパティを判別プロパティ(discriminant、ディスクリミネータ)と呼び、慣習的に kind や type、status といった名前を付けます。

通常のユニオン型との絞り込み結果の違い

違いは文章より、同じ処理を2通りで書いてコンパイラに通すほうが早く分かります。まず判別プロパティをリテラル型で定義した場合です。

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Shape = Circle | Square;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
  }
}

これは tsc --strict --noEmit がエラーなしで通ります。関数の戻り値が number と宣言されているのに default 節も末尾の return も無いのに通る点が重要で、2つの case でユニオンを使い切ったとコンパイラが判断しているためです。

次に、判別プロパティの型だけを string に変えます。

type Circle = { kind: string; radius: number };
type Square = { kind: string; size: number };
type Shape = Circle | Square;

function area(shape: Shape): number {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
  return shape.size ** 2;
}

TypeScript 7.0.2 は次のエラーを返します。

error TS2339: Property 'radius' does not exist on type 'Shape'.
  Property 'radius' does not exist on type 'Square'.
error TS2339: Property 'size' does not exist on type 'Shape'.
  Property 'size' does not exist on type 'Circle'.

shape.kind === "circle" という比較式そのものは受理されます。弾かれるのは、その後の shape.radius のように片方のメンバーにしかないプロパティへのアクセスです。判別プロパティは必ずリテラル型で書いてください。

判別プロパティに使えるboolean・数値・enumメンバのリテラル型

判別プロパティは文字列リテラルに限りません。成功と失敗の2値なら boolean のリテラル型が簡潔です。

type Ok = { ok: true; value: number };
type Err = { ok: false; error: string };
type Result = Ok | Err;

function show(r: Result): string {
  return r.ok ? String(r.value) : r.error;
}

これも --strict で通ります。同様に、文字列enumのメンバ(Kind.Circle のような形)を判別プロパティの型に指定した定義も、switch の case Kind.Circle: で絞り込めることを確認しています。要件は「リテラル型(単一の値だけを表す型)であること」であり、その値が何かは問われません。

型の基本的な書き分けそのものに不安がある場合は、TypeScriptとは?型・型推論・型注釈の書き分けとJavaScriptとの違い【2026年版】で型注釈と型推論の関係を先に押さえておくと読み進めやすくなります。

neverによるコンパイル時の網羅性検査

Discriminated Unionの実用上いちばん効くのが、メンバーを増やしたときに「分岐を書き忘れた場所」をコンパイラに列挙させる使い方です。ハンドブックのExhaustiveness checkingの節は never の性質を次のように説明しています。

The never type is assignable to every type; however, no type is assignable to never (except never itself).

すべての case を書き切っていれば default 節に到達する値の型は never に絞られ、never 型の変数へ代入できます。書き漏らしがあると、残ったメンバーは never に代入できないのでエラーになります。

その前に、--strict だけでも一定の検出は働きます。先ほどの Shape に Triangle を足して switch の分岐を2つのままにすると、TypeScript 7.0.2 は error TS2366: Function lacks ending return statement and return type does not include 'undefined'. を返します。ただしこのメッセージは「どのメンバーを取りこぼしたか」を教えてくれませんし、戻り値のない関数(void)では働きません。そこで never を使います。

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Square | Triangle;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    default: {
      const exhaustive: never = shape;
      return exhaustive;
    }
  }
}

Triangle の分岐を書いていないため、TypeScript 7.0.2 は先ほどのTS2366に代わって次を出力します。

error TS2322: Type 'Triangle' is not assignable to type 'never'.

エラーメッセージが不足しているメンバー名(Triangle)を名指しする点が実務上ありがたいところです。ユニオンに新しい状態を追加すると、このneverへの代入を置いた分岐では、未処理の状態が型チェック時にエラーになります。switch ではなく if の連鎖で書く場合も、最後に同じ const exhaustive: never = shape; を置けば同じ検査が働きます。

この「取りうる状態を型で数え上げて、漏れをコンパイラに検出させる」考え方は、成功と失敗を1つの値で表すエラーハンドリングとも相性が良い書き方です。鉄道指向プログラミングとは|Result型で書くエラーハンドリング入門では、同じ構造をエラー処理の設計へ広げた形を扱っています。

ナローイングが効かないときに疑う3点

1. オブジェクトリテラルの型の拡大(widening)

判別プロパティ側の定義は正しくても、代入する値の型が string へ広がって(widening)いると弾かれます。

const raw = { kind: "circle", radius: 2 };
const s: Shape = raw;
error TS2322: Type '{ kind: string; radius: number; }' is not assignable to type 'Shape'.
  Type '{ kind: string; radius: number; }' is not assignable to type 'Circle'.
    Types of property 'kind' are incompatible.
      Type 'string' is not assignable to type '"circle"'.

中間変数を挟まず const s: Shape = { kind: "circle", radius: 2 }; と直接書くか、as const を付けてリテラル型を保つと解決します。as const を付けた形でコンパイルが通ることは確認済みです。エラーメッセージが最終行で「string は "circle" に代入できない」と具体的に指してくれるので、原因の切り分けはこの行を読むのが早道です。

2. 分割代入の対象プロパティと宣言方法の制約

TypeScript 4.6 の「Control Flow Analysis for Destructured Discriminated Unions」により、分割代入した判別プロパティでの絞り込みが効くようになりました。ただし効く範囲には条件があります。

type Action =
  | { kind: "add"; payload: number }
  | { kind: "note"; payload: string };

function run({ kind, payload }: Action): string {
  if (kind === "add") return payload.toFixed(2);
  return payload.toUpperCase();
}

これは通ります。一方、次のように固有プロパティ名がメンバーごとに違う場合は、分割代入の時点で失敗します。

type Result = { ok: true; value: number } | { ok: false; error: string };

function show({ ok, value, error }: Result): string {
  return ok ? String(value) : error;
}
error TS2339: Property 'value' does not exist on type 'Result'.
error TS2339: Property 'error' does not exist on type 'Result'.

絞り込みが働く前にユニオン全体からプロパティを取り出そうとするためです。プロパティ名がメンバーごとに異なるなら、分割代入せず r.ok のようにオブジェクトのまま判定してから中身へ触ってください。逆に、payload のように名前が共通で型だけが違う場合は、constによる分割代入、または再代入されない引数の分割代入で、判別プロパティに応じた絞り込みが働きます。

宣言の仕方にも条件があります。同じ Action を let { kind, payload } = a; で受けると、TypeScript 7.0.2 は error TS2339: Property 'toFixed' does not exist on type 'string | number'. を返します。引数で分割代入した場合も、その変数を関数内で再代入すると同じエラーになります。値が変わりうる変数では、判別プロパティと他のプロパティの対応を追跡できないためです。const で受けるか、再代入しない引数として受けてください。

3. 判別プロパティがない型のin演算子による絞り込み

外部APIの型など、判別プロパティを後から足せないケースもあります。その場合は in 演算子でプロパティの有無から絞り込めます。

type Shape = { radius: number } | { size: number };

function area(shape: Shape): number {
  if ("radius" in shape) return Math.PI * shape.radius ** 2;
  return shape.size ** 2;
}

これも --strict で通ります。この形でもメンバー追加の検出はできます。ユニオンに { base: number } を足すと、最後の shape.size が error TS2339: Property 'size' does not exist on type '{ size: number; } | { base: number; }'. になりますし、候補を in で1つずつ絞り込んだうえで never への代入を置けば、前節と同じ網羅性検査が働きます。ただし判別プロパティがある場合と違い、絞り込みの条件をプロパティ名ごとに書き分ける必要があります。自分で定義できる型なら判別プロパティを持たせるほうが簡潔です。なお、string や number の別名を型レベルで区別したい(IDとメールアドレスを混同したくない等)という目的なら、判別プロパティではなくTypeScriptのBranded Types(ブランド型)とは|実装と型安全の設計指針で扱うブランド型のほうが適した道具です。

実務での使いどころとZodによる実行時検証

Discriminated Unionが効くのは、「同時には1つの状態しか取らないが、状態ごとに持つデータが違う」ものを表すときです。非同期処理の状態(読み込み中・成功・失敗)、APIレスポンス、UIイベント、Reduxのアクションなどが典型です。すべてのフィールドをオプショナルにした1つの型で表すと、data と error の両方が入った値や、両方とも無い値をコンパイラが許してしまいます。Discriminated Unionにすれば、成功時のdataや失敗時のerrorを必須にできます。ただしTypeScriptは構造的部分型を採用しているため、変数経由ではdataとerrorの両方を持つ値が代入できる場合があり、余分なプロパティまで一律に禁止する仕組みではありません。

type Fetch<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "failure"; error: Error };

ただし型はコンパイル時のものなので、外部から届くJSONそのものは守れません。実行時のバリデーションを担うライブラリ側にも、判別可能なユニオン専用のAPIがあります。Zod 4.6.1 で z.discriminatedUnion と通常の z.union に同じ不正データ({ kind: "circle", radius: "2" }、radiusが文字列)を通すと、返るエラーが次のように変わります。

// z.discriminatedUnion("kind", [...]) の issues
[ { "expected": "number", "code": "invalid_type", "path": ["radius"],
    "message": "Invalid input: expected number, received string" } ]

// z.union([...]) の issues
[ { "code": "invalid_union", "path": [], "message": "Invalid input",
    "errors": [ [ /* circle枝のエラー */ ], [ /* square枝のエラー */ ] ] } ]

z.discriminatedUnion は判別プロパティで枝を1つに決めてから検証するため、失敗箇所が radius だと一意に分かります。z.union は全部の枝を試して失敗を束ねるので、invalid_union の下に「square枝では kind が違う」という無関係なエラーまで入れ子で並びます。フォームのエラー表示やAPIの400レスポンスをそのまま組み立てるなら、この差は実装量に直結します。Zod自体の導入はZodの使い方入門|npm・yarn・pnpmでのインストールとTypeScript型安全バリデーションを参照してください。

TypeScript以外の言語での判別可能なユニオン

同じ考え方は他の言語やスキーマ定義にもあります。名称と、網羅漏れを検出できるかどうかを整理します。

言語・ツール 相当する機能 判別の方法 網羅漏れの検出
TypeScript 7.0.2 Discriminated Union リテラル型の共通プロパティ neverへの代入で検出(TS2322)
Python(Pydantic 2.13.5) Discriminated Unions Fieldのdiscriminator指定 検出しない(実行時に検証)
Rust 1.98.0 enum(列挙型) バリアント名 matchが検出(E0004)
Protocol Buffers oneof 設定済みフィールド 言語ごとの生成コードに依存

Python(Pydantic)のdiscriminated union

Pydantic 2.13.5 では、Field(discriminator="kind") を Annotated で付けると、TypeScriptと同じ「判別プロパティで枝を決める」検証になります。

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field

class Circle(BaseModel):
    kind: Literal["circle"]
    radius: float

class Square(BaseModel):
    kind: Literal["square"]
    size: float

Shape = Annotated[Union[Circle, Square], Field(discriminator="kind")]

class Box(BaseModel):
    shape: Shape

ここに {"kind": "triangle", "base": 1} をBoxのshapeフィールドに渡すと、判別値が定義済みのタグに一致しないことを示すunion_tag_invalidエラーになります。以下はメッセージの抜粋です。

1 validation error for Box
shape
  Input tag 'triangle' found using 'kind' does not match any of the expected tags:
  'circle', 'square' [type=union_tag_invalid, ...]

エラー種別が union_tag_invalid になり、期待されるタグの一覧まで示されます。Zodと同じく、判別プロパティを指定しない素のUnionでは全メンバー分の検証エラーが並ぶだけになるため、APIのエラーレスポンスの読みやすさが変わります。

Rustのenumとprotobufのoneof

Rustのenumは、バリアントごとに異なるデータを持てる点でDiscriminated Unionに相当します。判別プロパティを自分で用意する必要はなく、match の網羅性は言語仕様として強制されます。次のコードは3バリアントのうち2つしか処理していません。

enum Shape {
    Circle { radius: f64 },
    Square { size: f64 },
    Triangle { base: f64, height: f64 },
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Square { size } => size * size,
    }
}

これを rustc --edition 2021 s.rs でコンパイルすると、rustc 1.98.0 は次を返します(先頭行の抜粋です)。

error[E0004]: non-exhaustive patterns: `&Shape::Triangle { .. }` not covered

TypeScriptが never という書き方の工夫で得ている検査を、Rustは既定で行います。

Protocol Buffersの oneof も、スキーマ上で同じ構造を表します。公式ガイドは「Oneof fields are like optional fields except all the fields in a oneof share memory, and at most one field can be set at the same time.」と定義し、あるメンバーに値を設定すると他は自動的にクリアされると説明しています。ただし制約があり、同ガイドは「A oneof cannot be repeated.」と明記しています。繰り返しフィールドを入れたい場合は、繰り返しフィールドを持つメッセージをoneofの中に置く形で回避します。protoファイルの書き方はProtocol Buffersとは?protoファイルの書き方と採用判断を実装者向けに解説にまとめています。

よくある質問(FAQ)

Discriminated Unionと「タグ付きユニオン」「代数的データ型」は同じものですか?

ほぼ同じ構造を指しますが、文脈が違います。タグ付きユニオン(tagged union)は判別用のタグを持つユニオンという一般的な呼び名で、Discriminated UnionはTypeScriptやF#で使われる名称です。代数的データ型(ADT)は関数型言語で使われる用語で、この直和型に加えて直積型(タプルやレコード)も含む、より広い概念を指します。

判別プロパティの名前はkindとtypeのどちらにすべきですか?

言語仕様としてはどちらでも同じように動きます。TypeScript公式ハンドブックの例は kind を使っています。プロジェクト内で統一することのほうが重要です。ReduxのアクションやJSONスキーマなど、外部の慣習で type が決まっている場合はそれに合わせてください。

判別プロパティが2つ必要な場合はどうしますか?

判別用プロパティが2つあっても、許可する値の組み合わせごとにユニオンのメンバーを定義すれば絞り込みは働きます。避けたいのは、各プロパティを独立したユニオン型として1つのオブジェクト型にまとめてしまう書き方で、この形では意図しない組み合わせも型として許されます。組み合わせを列挙するとメンバー数が増えるなら、1段目の判別プロパティで大分類を絞り、絞られた型の中でさらに別の判別プロパティを持つユニオンを定義する、ネストした形にします。

クラスの継承とDiscriminated Unionはどう使い分けますか?

振る舞い(メソッド)が型ごとに違い、呼び出し側は共通のインターフェースだけ知っていればよいなら継承やインターフェースが向きます。データの形だけが違い、処理は呼び出し側で分岐したい場合や、値がJSONとして往復する場合はDiscriminated Unionが向きます。JSONだけではクラスのプロトタイプは復元されませんが、必須プロパティの有無などで型を判別する設計も可能です。判別プロパティは、状態や種類を明示して分岐させたい場合に適しています。

Discriminated Unionのメンバーが増えすぎたときはどうしますか?

まず never による網羅性チェックが入っているかを確認してください。必要な分岐それぞれに入っていれば、そこで処理されていないメンバーをコンパイルエラーで検出できます。ただし、分岐数の増加による可読性や変更範囲の拡大は別に管理する必要があります。そのうえで分岐処理が各所に散らばっているなら、判別プロパティをキーにしたオブジェクト(ハンドラのマップ)へ寄せると、分岐の記述を1か所にまとめられます。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.09.28 テックブログ タイムズカーの不正アクセスと約660万件の流出|免許証画像を退会者まで残さない保管設計
  2. 2026.09.25 コラム 最低賃金引き上げ【令和8年度】47都道府県の改定額・発効日と企業の対応手順
  3. 2026.09.25 コラム 障害者雇用の助成金一覧:月いくら・支給要件と申請書類を勤怠データで揃える方法
  4. 2026.09.05 コラム 犯罪収益移転防止法の本人確認:2027年4月の対面IC読み取り義務化と改修要件
  5. 2025.11.28 コラム ポリコレの意味・由来と具体例、「行き過ぎ・逆差別」批判まで中立にわかりやすく整理

RELATED POSTS 関連記事

目次