1. ホーム
  2. TypeScript

【TypeScript】判別可能なユニオン型(Discriminated Unions)の使い方|安全に型を絞り込む

Share

複数の型をまとめて扱えるユニオン型は便利ですが、「今このオブジェクトはどの型なのか」を判断してからプロパティにアクセスしないと、型エラーになってしまいます。そこで役立つのが判別可能なユニオン型(Discriminated Unions/タグ付きユニオン)です。各型に共通の目印プロパティを持たせておくと、switchif でその値を見るだけで TypeScript が型を自動で絞り込んでくれます。この記事では、判別可能なユニオンの作り方、状態管理を例にした実践的な使い方、そして never を使った網羅性チェックまで、初心者〜中級者向けに解説します。

ただのユニオン型では型を絞り込めない

まず、判別可能なユニオンを使わない場合に何が困るのかを見てみます。円と長方形という2つの図形をユニオン型でまとめ、面積を計算しようとするコードです。

no-discriminant.ts
type Circle = { radius: number };
type Rectangle = { width: number; height: number };
type Shape = Circle | Rectangle;

function area(shape: Shape): number {
  // shape が Circle か Rectangle か分からない
  return Math.PI * shape.radius ** 2;
  // エラー:radius は Rectangle には存在しない
}

ShapeCircleRectangle のどちらかですが、TypeScript から見るとどちらか判別できないため、shape.radius にアクセスするとエラーになります。Rectangle には radius が無いからです。どちらの型なのかを見分ける「目印」が無いことが原因です。

判別子(共通のリテラル型プロパティ)を持たせる

判別可能なユニオンでは、各型に「判別子(discriminant)」と呼ばれる共通のプロパティを持たせます。判別子は "circle""rectangle" のようなリテラル型にするのがポイントです。プロパティ名は何でもよいですが、kindtypetag がよく使われます。

shape.ts
type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Rectangle;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // ここでは shape は Circle に絞り込まれている
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      // ここでは shape は Rectangle に絞り込まれている
      return shape.width * shape.height;
  }
}

各型に kind というリテラル型のプロパティを追加しました。switch (shape.kind) で分岐すると、case "circle" のブロックの中では TypeScript が「shapeCircle だ」と理解し、shape.radius に安全にアクセスできます。同様に case "rectangle" の中では widthheight が使えます。判別子を見るだけで型が自動的に絞り込まれるのが、判別可能なユニオンの核心です。

状態管理でよく使うパターン

判別可能なユニオンが特に威力を発揮するのが、非同期処理の状態管理です。「読み込み中」「成功」「失敗」といった状態は、それぞれ持っているデータが違います。これをタグ付きユニオンで表すと、状態ごとに必要なデータだけを型で強制でき、存在しないデータへのアクセスを防げます。

fetch-state.ts
type FetchState =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "読み込み中...";
    case "success":
      // data はこのブロックでだけ使える
      return `${state.data.length} 件取得しました`;
    case "error":
      // message はこのブロックでだけ使える
      return `エラー: ${state.message}`;
  }
}

status が判別子です。"success" のときだけ data を、"error" のときだけ message を持つように定義しているため、たとえば "loading" の状態で state.data にアクセスしようとするとエラーになります。「読み込み中なのにデータを参照してしまう」といったバグを、型の段階で防げるわけです。

if 文でも絞り込める

絞り込みは switch 専用ではありません。if で判別子を比較しても同じように型が絞り込まれます。分岐が2つ程度なら if のほうが読みやすいこともあります。

if-narrowing.ts
function describe(shape: Shape): string {
  if (shape.kind === "circle") {
    // shape は Circle に絞り込まれる
    return `半径 ${shape.radius} の円`;
  }
  // ここに来た時点で shape は Rectangle に絞り込まれる
  return `${shape.width} × ${shape.height} の長方形`;
}

if (shape.kind === "circle") の中では Circle に絞り込まれ、その if を抜けた後は残る型(ここでは Rectangle)に絞り込まれます。このように、判別子の比較さえあれば switch でも if でも型が狭まります。

never で漏れを防ぐ網羅性チェック

判別可能なユニオンの大きな利点が、網羅性チェック(exhaustiveness check)です。never 型を使うと、「ユニオンに新しいメンバーを追加したのに分岐を書き忘れた」ケースをコンパイル時に検出できます。すべての case を処理し終えた default では、変数の型は never になっているはずだ、という仕組みを利用します。

exhaustive.ts
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number }; // 後から追加

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // triangle の case を書き忘れている
    default: {
      // すべて処理済みなら shape は never のはず
      const _exhaustive: never = shape;
      // triangle が残っているためエラーになる
      return _exhaustive;
    }
  }
}

default の中で const _exhaustive: never = shape; と書いておくと、もし処理していない kind(この例では "triangle")が残っている場合、shapenever ではないためコンパイルエラーになります。これにより、ユニオンにメンバーを追加したときに「分岐の追加漏れ」を型が教えてくれます。trianglecase を書けばエラーは消えます。状態やアクションの種類が増えていくコードほど、この仕組みが安全網になります。

うまく絞り込めないときに確認すること

判別可能なユニオンを書いたのに型が絞り込まれない場合、多くは判別子の作り方に原因があります。

判別子がリテラル型になっていない

判別子は "circle" のようなリテラル型である必要があります。もし判別子のプロパティを string 型にしていると、値を比較しても型を1つに特定できず、絞り込みが働きません。オブジェクトリテラルから型を作る場合は、値が string に広がらないよう as const を付けるか、明示的にリテラル型で型注釈を書きます。

判別子のプロパティ名が型ごとにずれている

絞り込みが働くのは、すべてのメンバーが同じ名前の判別子プロパティを持っているときです。ある型では kind、別の型では type のように名前が食い違っていると、共通の判別子として認識されません。ユニオン内で判別子の名前を統一してください。

まとめ

判別可能なユニオン型(タグ付きユニオン)は、各メンバーに kindstatus といった共通のリテラル型プロパティ(判別子)を持たせ、その値を switchif で見ることで型を安全に絞り込む書き方です。絞り込まれたブロックの中では、その型に固有のプロパティへ型エラーなくアクセスでき、存在しないデータを参照するバグを型の段階で防げます。さらに defaultnever を使えば、メンバーを追加したときの分岐漏れを検出する網羅性チェックが実現できます。API のレスポンスや状態管理など、「状況によって持っているデータが変わる」データを扱うときにぜひ活用してみてください。

参考ページ