Menu

TypeScriptの判別可能なユニオン型(タグ付きユニオン)

判別可能なユニオン型とは、kind や status のような共通のリテラルのタグプロパティを持つオブジェクト型のユニオンです。タグをチェックするとオブジェクト全体が絞り込まれます。パターンの書き方、switch による絞り込み、never による網羅性チェック、APIの結果、リクエストの状態、状態機械のモデル化を解説します。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

判別可能なユニオン型(タグ付きユニオンとも呼ばれます)は、すべてのメンバーが1つのプロパティ、つまりタグを共有し、メンバーごとにそのリテラルの値が違うオブジェクト型のユニオンです。タグをチェックすると、どのメンバーなのかがTypeScriptにわかり、そのメンバーのほかのプロパティが使えるようになります。

case "circle" の中で shape.width を読むとコンパイルエラーになります。円のメンバーには width がないからです。タグは普通のデータです。実行時にはただの文字列のプロパティで、switch は普通のJavaScriptです。

ユニオン型が判別可能になる条件

タグによる絞り込みは、次の3つの条件がそろったときに働きます:

  1. すべてのメンバーがオブジェクト型である。
  2. すべてのメンバーが同じ名前のプロパティ(判別子)を持つ。名前は自由で、kind、type、status、tag がよく使われます。
  3. 各メンバーで、そのプロパティがリテラル型である。文字列、数値、boolean のリテラル、または null/undefined です。
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

あるメンバーがタグをリテラルではなく普通の string として宣言していると、タグを比較してもユニオン型は絞り込まれなくなり、メンバー固有のプロパティには手が届かないままになります(TS2339)。

タグによる絞り込み

タグに対するチェックでTypeScriptが理解できるものなら、どれでもオブジェクトを絞り込みます。switch、if/else、=== と !==、そして const であれば分割代入したタグでもかまいません:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

"radius" in shape でプロパティの有無をチェックしても絞り込めますが、タグを比較するほうが読みやすく、網羅性のチェックもできるようになります。

網羅性チェック

このパターンの最大の利点は、ユニオン型が大きくなったときに表れます。明示的な戻り値の型があり default がなければ、switch がすべてのタグをカバーしなければならないことをTypeScriptは知っているので、ケースのない新しいメンバーはコンパイルエラーになります:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

このメッセージは、どのケースが足りないかを教えてくれません。assertNever ヘルパーなら教えてくれ、外部からのデータ(JSON、古いクライアント)が型の上では存在しえないタグを持っていたときには、実行時に例外も投げます:

ケースを追加せずに5つ目の status を追加すると、assertNever(state) の行で Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never' が報告され、メンバーが名指しされます。詳しくは never のページを参照してください。

ありえない状態をありえなくする

上の RequestState は、よくある弱い形の代わりになります:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

省略可能なフィールドでは「データとエラーの両方を持つ読み込み中」も型チェックを通り、読む人はどの組み合わせが本当に起こりうるかを推測しなければなりません。判別可能なユニオン型なら、data は success の状態にしか、error は error の状態にしか存在しないので、state.data を読むコードは先に success の状態であることを証明しなければなりません。型そのものが正しい状態を文書化しています。

結果のモデル化: 成功か失敗か

「うまくいったか、いかなかったか」なら boolean のタグで十分です。この Result 型は、不正な入力のような想定される失敗に対して、例外を投げる代わりによく使われます:

呼び出し側は、先に result.ok をチェックしなければ result.value を読めません。例外や null を返す方法では、まさにこのチェックを忘れがちです。

状態機械とリデューサー

状態用とアクション用の2つの判別可能なユニオン型で、状態機械を表せます。リデューサーはアクションで分岐して次の状態を返し、コンパイラーは返した各オブジェクトが正しい状態であるかをチェックします:

{ ...state, name: "paused" } がコンパイルできるのは、先に state が再生中のメンバーに絞り込まれているので、コピーが track と position を持つからです。{ name: "paused" } だけを返すとエラーになります。一時停止の状態にはトラックが必要だからです。Redux のリデューサーや React の useReducer が使っているのと同じ形です。

型の拡大の落とし穴

タグはリテラル型のままでなければなりません。型注釈なしで変数に作ったオブジェクトは、タグが string に広げられ、どのメンバーにも一致しなくなります:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

その背景にあるルール、つまり変更可能なプロパティは型が広がることは、リテラル型 のページで説明しています。

よくある質問

TypeScriptの判別可能なユニオン型とは何ですか?

すべてのメンバーが同じプロパティ(判別子、またはタグ)を異なるリテラル型で持つオブジェクト型のユニオンです。たとえば { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number } です。shape.kind === "circle" をチェックすると shape が円のメンバーに絞り込まれ、そのほかのプロパティが使えるようになります。

ユニオン型と判別可能なユニオン型の違いは何ですか?

判別可能なユニオン型は、ルールが1つ加わったユニオン型です。すべてのメンバーが、固有のリテラル型を持つプロパティを共有します。Cat | Fish のような普通のユニオン型は in や独自の型ガードで絞り込む必要がありますが、判別可能なユニオン型は1つのプロパティを比較するだけで絞り込め、そのプロパティに対する switch は網羅性をチェックできます。

判別可能なユニオン型に対する switch を網羅的にするには?

関数に明示的な戻り値の型を付けて default を書かない(ケースが足りないとエラー TS2366 になる)か、function assertNever(x: never): never { throw ... } と組み合わせて default: return assertNever(value) を追加します。2つ目の形は、エラーの中で処理されていないメンバーを名指しし、想定外のデータが届いたときには実行時に例外も投げます。

判別可能なユニオン型が絞り込まれないのはなぜですか?

タグはすべてのメンバーでリテラル型でなければなりません。型注釈なしで作ったオブジェクトは kind が string に広げられ、どのメンバーにも一致しなくなります(TS2345/TS2322)。型注釈、as const、またはユニオン型が期待される場所で作ることで直します。kind: string と型付けされたメンバーがあっても、ユニオン型は kind で絞り込めなくなります。

判別子は boolean や数値でもよいですか?

かまいません。どんなリテラル型でも使えます。文字列(kind: "circle")、数値(version: 2)、boolean(ok: true / ok: false)、さらに null や undefined もです。ログに出したりJSONで送ったりしたときに読みやすいので、文字列のタグがいちばんよく使われます。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める