TypeScript | 基礎文法:Union・基本型操作 – Discriminated Unionの考え方

TypeScript
スポンサーリンク

まず「Discriminated Union」をざっくり日本語にすると

難しいカタカナですが、意味はシンプルです。
Discriminated Union(判別可能ユニオン)=「ある1つのプロパティの値によって、どの型なのかを“判別できる”Union型」 です。

もっとラフに言うと、

  • 「状態を表す文字列」みたいな印をひとつ決めておいて
  • その値ごとに「中身の型」を変えるやり方

です。

TypeScript 的には、
その「印になるプロパティ」(たとえば statustypekind)を見て、
if や switch の中で自動的に型を絞り込んでくれる、とても相性のいい書き方です。

ここから、失敗しづらい形で、具体例で掘り下げていきます。


一番典型的な形:statusで状態を切り替える

状態を4パターン持つUnion型

たとえば「読み込み処理の状態」を型で表したいとします。

  • まだ何もしていない(idle)
  • 読み込み中(loading)
  • 成功してデータを持っている(success)
  • 失敗してエラーを持っている(error)

これを Discriminated Union で書くと、こうなります。

type LoadingState =
  | {
      status: "idle";
    }
  | {
      status: "loading";
    }
  | {
      status: "success";
      data: string;
    }
  | {
      status: "error";
      error: string;
    };
TypeScript

ここで重要なのは、

  • status全パターンに共通して存在する
  • status の値が、それぞれ "idle" | "loading" | "success" | "error" のように「被らないリテラル文字列」
  • 他のプロパティ(data, error)は「ある状態のときだけ存在する」

という構造です。

この「全パターンが同じ名前のプロパティ(ここでは status)を持ち、その値がパターンごとに違う」
という形が、Discriminated Union の核心です。

if / switch で分けたときに型が勝手に絞られる

この LoadingState を使うと、こんな関数が書けます。

function render(state: LoadingState) {
  switch (state.status) {
    case "idle":
      console.log("待機中");
      break;
    case "loading":
      console.log("読み込み中…");
      break;
    case "success":
      console.log("成功:", state.data);
      break;
    case "error":
      console.log("失敗:", state.error);
      break;
  }
}
TypeScript

ここで起きている「型の世界」を言葉で追うと、こうなります。

  • case "idle" の中
    state{ status: "idle" } 型に絞り込まれる
  • case "loading" の中
    { status: "loading" } 型に絞り込まれる
  • case "success" の中
    { status: "success"; data: string } 型に絞られるので、state.data が安全に使える
  • case "error" の中
    { status: "error"; error: string } 型に絞られるので、state.error が安全に使える

つまり、status の値を見て分岐すると、その分岐内では「その状態に応じた型」に自動で狭まってくれる わけです。
これがまさに「判別可能(Discriminated)」な Union である理由です。


なぜ「判別用プロパティ」を1つ決めると嬉しいのか

「今どのバリエーションなのか」が一目でわかる

さっきの例だと、status という1つのプロパティを見れば、
そのオブジェクトが「今どの状態なのか」がすぐ分かります。

  • status: "idle" → 何もしてない
  • status: "loading" → 読み込み中
  • status: "success" → 成功していて、data がある
  • status: "error" → 失敗していて、error がある

コードを読む人間にとっても分かりやすいし、
TypeScript にとっても「ここは success のケースだから data があるよね」と理解しやすい形になっています。

「状態ごとにあるプロパティ」が自然に書ける

たとえば、「成功のときだけ data がある」ような設計は、
Discriminated Union で書くととても自然です。

type SuccessState = {
  status: "success";
  data: string;
};
TypeScript

これだけで、

  • success のときは必ず data がある
  • success 以外のときは data がない

というルールが、型レベルで保証されます。

もしどこかで、

if (state.status === "success") {
  console.log(state.error); // これはエラー
}
TypeScript

と書いたら、TypeScript が「success 状態には error はないよ」と怒ってくれます。
「状態によって何がある/ない」を、型としてキレイに表せる。
そして、そのルールから外れたコードを書こうとすると、ちゃんと止めてくれる。

ここが Discriminated Union の一番おいしいところです。


別の例:図形の種類をkindで判別する

Circle / Square / Rectangle などを1つの型にまとめる

図形を扱いたいときも、Discriminated Union がよく使われます。

type Circle = {
  kind: "circle";
  radius: number;
};

type Square = {
  kind: "square";
  size: number;
};

type Rectangle = {
  kind: "rectangle";
  width: number;
  height: number;
};

type Shape = Circle | Square | Rectangle;
TypeScript

ここでは kind が判別用プロパティです。
kind の値を見れば、どの図形か一発で分かります。

kind で分岐して型を絞り込む

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

しれっと書いていますが、型の世界ではこうなっています。

  • case "circle" の中では shapeCircle 型に絞られ、radius が使える
  • case "square" の中では Square 型で、size が使える
  • case "rectangle" の中では Rectangle 型で、width, height が使える

もし間違えて、たとえば case "square" の中で shape.radius と書いたら、
TypeScript に怒られます。

「square 型には radius はないよ」と。

「kind の値」と「図形ごとのプロパティ」がリンクしている からこそ、
TypeScript が強力にチェックしてくれる、というわけです。


よくある失敗パターンと、Discriminated Union での解決

悪い例:判別用プロパティを決めていないUnion

例えば、こんな型を考えます。

type BadShape =
  | { radius: number }
  | { size: number };
TypeScript

ここには「判別用プロパティ」がありません。
radius があるかどうかで見分けたくなりますが、
実務が複雑になるとこういう判定は増えすぎてぐちゃぐちゃになりがちです。

また、switch もしづらいです。
なぜなら「何を基準にケースを分けるのか」が曖昧だから。

これを Discriminated Union にすると、こうなります。

type GoodShape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };
TypeScript

すると、分岐は必ず shape.kind ベースで書けるようになります。

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

「1つの明確な“判別軸”を決める」ことで、分岐のロジックも型の絞り込みもきれいに整理される
これが Discriminated Union の強さです。


設計の視点:どうやって「判別用プロパティ」を選ぶか

その値だけ見れば「何パターンのうちどれか」が分かる軸を探す

Discriminated Union を設計するとき、一番考えるべきことはこれです。

「このドメイン(問題の世界)で、
このオブジェクトの“ケース”を分ける自然な軸は何か?」

状態なら statusstate
種類なら typekind
ロールなら role

などが典型的です。

たとえばチャットアプリで、

  • テキストメッセージ
  • 画像メッセージ
  • システムメッセージ

みたいなものを扱うなら、こんな感じにできます。

type Message =
  | { type: "text"; text: string }
  | { type: "image"; url: string }
  | { type: "system"; code: number; message: string };
TypeScript

ここでも type が判別用プロパティです。

Message を扱う関数は、たいてい message.type を見て分岐します。
このとき、今どのパターンかが一瞬で分かり、
かつ TypeScript が型を絞り込んでくれるので、
実装と型と読みやすさが、きれいにそろいます。

判別用プロパティは「全ケース共通」「値が被らない」ことが重要

Discriminated Union をちゃんと機能させるには、次の2つが大事です。

全てのケースに、その判別用プロパティが必ず存在する
各ケースで、そのプロパティの値が“被らない”

例えば status なら、

idle / loading / success / error

のように、各状態で異なる文字列リテラルを持たせる必要があります。

もしどこかで値が被ってしまうと、
TypeScript からすると「status が ‘done’ のとき、この2つの型どちらか分からない」
という状態になり、絞り込みがうまく働かなくなってしまいます。


まとめ:Discriminated Unionを自分の言葉で言うと

自分の言葉で整理すると、Discriminated Union はこういうものです。

「状態や種類を表す“ラベル”用プロパティを1つ決めておいて、
その値ごとに中身の型を変える Union。
そのラベルを見れば、どの型なのかが一目で分かるようになっている。」

そして、

そのラベル(statustype)を if / switch で分岐すると、
TypeScript が自動的に「じゃあこの中ではこの型だね」と型を絞り込んでくれる。

だから、

状態ごとに違うプロパティを持つ
種類ごとに違う情報を抱える

といった「現実の“場合分け”」を、
きれいに、安全に、そのまま型で表現できる。

コードを書いていて、

「これはいくつかの“パターン”があって、そのパターンごとに持ってる情報が違うな」

と感じたら、そこが Discriminated Union を検討するタイミングです。

そのとき、

この世界では “何” を見ればパターンが分かる?

を自分に問いかけて、その答えを status, type, kind などの判別用プロパティとして形にしてみてください。
それが、そのまま TypeScript にとっての「判別の軸」になり、
あなたの頭の中の“場合分け”と、コンパイラの“型の理解”が、ぴったり重なり始めます。

タイトルとURLをコピーしました