判別可能なユニオン型(タグ付きユニオン)とは、ユニオン型の各メンバーに同じ名前のプロパティを持たせ、その値("loading" や true などのリテラル型)で「いまどのメンバーか」を見分けられるようにした型です。 switch や if でそのプロパティを比べるだけで、TypeScript が残りのプロパティの型まで絞り込んでくれます。見分けるためのプロパティを、この記事では「判別子」と呼びます。

この記事では、読み込み状態・API レスポンス・reducer のアクション・フォームのステップの4題材を、そのまま貼って動く型と switch のコードで載せます。さらに、同じ判定でも書き方によって絞り込める場合と絞り込めない場合があることを早見表にまとめ、絞り込めないときに tsc が出すエラーを全文で掲載しました。

確認環境:Ubuntu 24.04、TypeScript 7.0.2(npx tsc --version の表示は Version 7.0.2)、Node.js 22.22.2。エラー文はすべて npx tsc -p . の出力をそのまま貼っています。

絞り込める書き方・絞り込めない書き方の早見表

state が判別可能なユニオン型のとき、判定の書き方ごとに絞り込めるかどうかをまとめました。○はエラーなしで型チェックを通ったもの、×は TS2339 が出たものです。

書き方 例 絞り込み 直し方
判別子を直接比べる if (state.status === "success") ○ —
switch で判別子を分ける switch (state.status) ○ —
判別子の型が string のまま type Circle = { kind: string } × リテラル型(kind: "circle")にする
判別子がネストしている result.meta.kind === "loaded" × 判別子をいちばん外側に置く
const でまとめて分割代入してから判定 const { type, payload } = action ○ —
引数でまとめて分割代入 function f({ type, payload }: Action) ○ —
let で分割代入 let { type, payload } = action × const にする
別々の文で分割代入 const { type } = action; const { payload } = action; × 1つの文にまとめる
残余プロパティで受ける const { type, ...rest } = action × action.payload を直接読む
判別子を const の別変数に入れる const status = state.status ○ —
判定結果を const に入れる const isSuccess = state.status === "success" ○ —
判別子を let の別変数に入れる let status = state.status × const にする
関数に判定を任せる(戻り値が boolean) if (isSuccessStatus(state.status)) × 直接比べるか、型述語を返す関数にする
埋め込みなしのテンプレートリテラル state.status === `fetch_success` ○ —
埋め込みありのテンプレートリテラル state.status === `${prefix}_success` × as const を付ける
as const なしの定数オブジェクトと比べる state.status === STATUS.SUCCESS × 定数オブジェクトに as const を付ける
endsWith・includes で判定 state.status.endsWith("success") × === で比べる

迷ったら「判別子はいちばん外側にリテラル型で置き、=== か switch で直接比べる」形にすると、どの題材でも絞り込めます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript 判別可能なユニオン型の使い方|switchで絞り込む」

判別可能なユニオン型を作る3つの条件

判別可能なユニオン型として働くための条件は次の3つです。

条件 内容
同じ名前のプロパティ すべてのメンバーが、同じ名前のプロパティ(判別子)を持つ
リテラル型 判別子の型が "loading"・200・true のようなリテラル型になっている
値が重ならない メンバーごとに判別子の値が異なる

判別子の名前は自由です。type・kind・status・ok・step などがよく使われます。1つの型定義の中では名前をそろえておくと読みやすくなります。

以下のコードは、次の tsconfig.json を置いたフォルダで型チェックしています。

{
  "compilerOptions": {
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "moduleDetection": "force",
    "noEmit": true
  }
}

型チェックは npx tsc -p .、実行は node ファイル名.ts です(Node.js 22.22.2 は型注釈を取り除いて .ts をそのまま実行できます)。

題材1:読み込み中・成功・エラーの状態を1つの型で表す

データ取得の状態を「loading フラグ・data・error の3つの変数」で持つと、「読み込み中なのに data がある」といった組み合わせも型の上では作れてしまいます。判別可能なユニオン型にすると、状態ごとに持てるプロパティが決まり、ありえない組み合わせを型で作れなくなります。

// fetch-state.ts
type User = { id: number; name: string };

type FetchState =
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; message: string };

function render(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "読み込み中…";
    case "success":
      return `${state.data.length}件: ${state.data.map((u) => u.name).join(", ")}`;
    case "error":
      return `エラー: ${state.message}`;
  }
}

console.log(render({ status: "loading" }));
console.log(render({ status: "success", data: [{ id: 1, name: "sato" }, { id: 2, name: "suzuki" }] }));
console.log(render({ status: "error", message: "タイムアウトしました" }));
$ node fetch-state.ts
読み込み中…
2件: sato, suzuki
エラー: タイムアウトしました

case "success": の中では state が { status: "success"; data: User[] } に絞り込まれるので、state.data をそのまま読めます。case "loading": の中で state.data と書くと、その場でエラーになります。

題材2:API レスポンスを ok: true / false で分ける

判別子は文字列でなくてもかまいません。成功か、そうでないかの2択なら、真偽値の ok を判別子にすると if と else だけで書けます。

// api-result.ts
type ApiResult<T> =
  | { ok: true; value: T }
  | { ok: false; error: { code: number; message: string } };

function parseQuantity(input: string): ApiResult<number> {
  const n = Number(input);
  if (!Number.isInteger(n) || n < 0) {
    return { ok: false, error: { code: 400, message: `数量として読めません: ${input}` } };
  }
  return { ok: true, value: n };
}

for (const input of ["12", "abc"]) {
  const result = parseQuantity(input);
  if (result.ok) {
    console.log("数量:", result.value + 1);
  } else {
    console.log(result.error.code, result.error.message);
  }
}
$ node api-result.ts
数量: 13
400 数量として読めません: abc

if (result.ok) の中では value だけが、else の中では error だけが読めます。result.ok === true と書いても同じように絞り込めます。

題材3:reducer のアクションを type で分ける

状態と操作(アクション)を受け取って次の状態を返す関数を reducer と呼びます。アクションは「種類ごとに持つデータが違う」ので、判別可能なユニオン型がそのまま当てはまります。

// reducer.ts
type Task = { id: number; title: string; done: boolean };
type State = { tasks: Task[]; nextId: number };

type Action =
  | { type: "added"; title: string }
  | { type: "toggled"; id: number }
  | { type: "removed"; id: number }
  | { type: "cleared" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "added":
      return {
        tasks: [...state.tasks, { id: state.nextId, title: action.title, done: false }],
        nextId: state.nextId + 1,
      };
    case "toggled":
      return {
        ...state,
        tasks: state.tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t)),
      };
    case "removed":
      return { ...state, tasks: state.tasks.filter((t) => t.id !== action.id) };
    case "cleared":
      return { ...state, tasks: [] };
    default: {
      const unreachable: never = action;
      throw new Error(`想定していないアクション: ${JSON.stringify(unreachable)}`);
    }
  }
}

const actions: Action[] = [
  { type: "added", title: "備品を発注する" },
  { type: "added", title: "在庫を数える" },
  { type: "toggled", id: 1 },
  { type: "removed", id: 2 },
];

let state: State = { tasks: [], nextId: 1 };
for (const action of actions) {
  state = reducer(state, action);
  console.log(action.type.padEnd(8), JSON.stringify(state.tasks));
}
$ node reducer.ts
added    [{"id":1,"title":"備品を発注する","done":false}]
added    [{"id":1,"title":"備品を発注する","done":false},{"id":2,"title":"在庫を数える","done":false}]
toggled  [{"id":1,"title":"備品を発注する","done":true},{"id":2,"title":"在庫を数える","done":false}]
removed  [{"id":1,"title":"備品を発注する","done":true}]

"toggled" と "removed" はどちらも id を持ちますが、別のメンバーとして書いておくと、あとで片方だけにプロパティを足すときに影響が広がりません。default の中の never への代入は、アクションを追加したときに case の書き漏れを知らせてくれる仕組みです(後半の「ケースを追加したときの網羅性エラー」で扱います)。

題材4:フォームのステップを数値の判別子で分ける

判別子には数値のリテラル型も使えます。 入力が進むにつれて持つ値が増えていくフォームは、ステップ番号を判別子にすると「ステップ2ならメールアドレスが必ずある」ことを型で表せます。

// form-step.ts
type FormStep =
  | { step: 1; name: string }
  | { step: 2; name: string; email: string }
  | { step: 3; name: string; email: string; agreed: boolean };

function next(form: FormStep, input: string): FormStep {
  switch (form.step) {
    case 1:
      return { step: 2, name: form.name, email: input };
    case 2:
      return { step: 3, name: form.name, email: form.email, agreed: input === "yes" };
    case 3:
      return form;
  }
}

function title(form: FormStep): string {
  switch (form.step) {
    case 1:
      return "1/3 名前の入力";
    case 2:
      return `2/3 確認(${form.email})`;
    case 3:
      return `3/3 ${form.agreed ? "送信できます" : "同意が必要です"}`;
  }
}

let form: FormStep = { step: 1, name: "sato" };
console.log(title(form));
form = next(form, "sato@example.com");
console.log(title(form));
form = next(form, "yes");
console.log(title(form));
$ node form-step.ts
1/3 名前の入力
2/3 確認(sato@example.com)
3/3 送信できます

case 1: の中で form.email と書くとエラーになるので、「まだ入力されていない値を読む」コードを書いた時点で気づけます。

絞り込めない書き方と tsc のエラー全文

ここからは、早見表で×だった書き方を1つずつ再現します。出るエラーはどれも TS2339(そのプロパティは型に存在しない)で、2行目に「ユニオンのどのメンバーに無いのか」が書かれているのが読み方のポイントです。

判別子が string 型のまま

// e1-string.ts
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;
}
e1-string.ts(7,28): error TS2339: Property 'radius' does not exist on type 'Shape'.
  Property 'radius' does not exist on type 'Square'.
e1-string.ts(9,16): error TS2339: Property 'size' does not exist on type 'Shape'.
  Property 'size' does not exist on type 'Circle'.

kind が string だと、Square の kind も "circle" になりえるため、比べても候補が減りません。kind: "circle" と kind: "square" に書き換えると通ります。同じエラーを npx tsc -p . --locale ja で出すと、次の文になります。

e1-string.ts(7,28): error TS2339: プロパティ 'radius' は型 'Shape' に存在しません。
  プロパティ 'radius' は型 'Square' に存在しません。

型定義は正しくても、値を作る側で判別子が string に広がることもあります。

// e1b-infer.ts
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };

const input = { kind: "circle", radius: 2 };
const shape: Shape = input;

const fixed = { kind: "circle", radius: 2 } as const;
const shape2: Shape = fixed;
const fixed3 = { kind: "circle", radius: 2 } satisfies Shape;
const shape3: Shape = fixed3;
e1b-infer.ts(6,7): error TS2322: Type '{ kind: string; radius: number; }' is not assignable to type 'Shape'.
  Type '{ kind: string; radius: number; }' is not assignable to type '{ kind: "circle"; radius: number; }'.
    Types of property 'kind' are incompatible.
      Type 'string' is not assignable to type '"circle"'.

型注釈なしで変数に入れると kind は string と推論されます。as const か satisfies Shape を付けた2つ(shape2・shape3)はエラーになりません。最初から const input: Shape = { ... } と型注釈を書く形でも同じく通ります。

判別子がネストしている

// e2-nested.ts
type Loaded = { meta: { kind: "loaded" }; items: string[] };
type Failed = { meta: { kind: "failed" }; reason: string };
type Result = Loaded | Failed;

function show(result: Result): string {
  if (result.meta.kind === "loaded") {
    return result.items.join(",");
  }
  return result.reason;
}
e2-nested.ts(7,19): error TS2339: Property 'items' does not exist on type 'Result'.
  Property 'items' does not exist on type 'Failed'.
e2-nested.ts(9,17): error TS2339: Property 'reason' does not exist on type 'Result'.
  Property 'reason' does not exist on type 'Loaded'.

result.meta.kind を比べて絞り込まれるのは result.meta までで、外側の result は Result のままです。判別子は、絞り込みたいオブジェクトの直下に置きます。 上の例なら { kind: "loaded"; items: string[] } の形にすると通ります。

分割代入してから判定する

分割代入は、書き方によって結果が分かれます。

// e3-destructure.ts
type Action =
  | { type: "added"; payload: string }
  | { type: "removed"; payload: number };

// (1) const でまとめて分割代入 → 絞り込める
function a(action: Action) {
  const { type, payload } = action;
  if (type === "added") {
    payload.toUpperCase();
  }
}

// (2) 引数でまとめて分割代入 → 絞り込める
function b({ type, payload }: Action) {
  if (type === "added") {
    payload.toUpperCase();
  }
}

// (3) let で分割代入 → 絞り込めない
function c(action: Action) {
  let { type, payload } = action;
  if (type === "added") {
    payload.toUpperCase();
  }
}

// (4) 別々の文で取り出す → 絞り込めない
function d(action: Action) {
  const { type } = action;
  const { payload } = action;
  if (type === "added") {
    payload.toUpperCase();
  }
}

// (5) 残余プロパティ → 絞り込めない
function e(action: Action) {
  const { type, ...rest } = action;
  if (type === "added") {
    rest.payload.toUpperCase();
  }
}
e3-destructure.ts(24,13): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.
e3-destructure.ts(33,13): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.
e3-destructure.ts(41,18): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.

エラーになったのは (3)(4)(5) の3つです。const または引数で、判別子と一緒に1つの文で取り出した変数どうしは、取り出したあとも関係が保たれます。 let にする、文を分ける、残余プロパティにまとめる、のいずれかでこの関係が切れ、payload は string | number のままになります。

判別子を別変数に入れてから判定する

// e4-alias.ts
type State =
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

// (1) const の別変数に入れる → 絞り込める
function a(state: State) {
  const status = state.status;
  if (status === "success") {
    state.data.length;
  }
}

// (2) 判定結果を const に入れる → 絞り込める
function b(state: State) {
  const isSuccess = state.status === "success";
  if (isSuccess) {
    state.data.length;
  }
}

// (3) let の別変数に入れる → 絞り込めない
function c(state: State) {
  let status = state.status;
  if (status === "success") {
    state.data.length;
  }
}

// (4) 関数で判定する → 絞り込めない
function isSuccessStatus(status: string): boolean {
  return status === "success";
}
function d(state: State) {
  if (isSuccessStatus(state.status)) {
    state.data.length;
  }
}

// (5) あとで再代入される let 変数を、コールバックの中で読む → 絞り込みが切れる
function e(initial: State) {
  let state = initial;
  if (state.status === "success") {
    setTimeout(() => {
      state.data.length;
    }, 0);
  }
  state = initial;
}
e4-alias.ts(25,11): error TS2339: Property 'data' does not exist on type 'State'.
  Property 'data' does not exist on type '{ status: "error"; message: string; }'.
e4-alias.ts(35,11): error TS2339: Property 'data' does not exist on type 'State'.
  Property 'data' does not exist on type '{ status: "error"; message: string; }'.
e4-alias.ts(44,13): error TS2339: Property 'data' does not exist on type 'State'.
  Property 'data' does not exist on type '{ status: "error"; message: string; }'.

(1)(2) のように const に入れた場合は、もとの state まで絞り込まれます。(3) の let は途中で値が変わりうるため対象外です。(4) は関数の戻り値がただの boolean なので、呼び出し側には「どのメンバーか」が伝わりません。判定を関数に切り出したいときは、state is Extract<State, { status: "success" }> のような型述語を戻り値に書きます。(5) は、コールバックが動く時点で state が別の値に変わっているかもしれないため、絞り込みが引き継がれません。コールバックの外で const data = state.data; と取り出しておくと通ります。

テンプレートリテラルや定数と比べる

// e5-template.ts
type State =
  | { status: "fetch_success"; data: string[] }
  | { status: "fetch_error"; message: string };

// (1) 埋め込みのないテンプレートリテラル → 絞り込める
function a(state: State) {
  if (state.status === `fetch_success`) {
    state.data.length;
  }
}

// (2) 埋め込みのあるテンプレートリテラル → 絞り込めない
function b(state: State, prefix: "fetch") {
  if (state.status === `${prefix}_success`) {
    state.data.length;
  }
}

// (3) as const を付ける → 絞り込める
function c(state: State, prefix: "fetch") {
  if (state.status === (`${prefix}_success` as const)) {
    state.data.length;
  }
}

// (4) const 変数に入れて比較 → 絞り込める
function d(state: State) {
  const SUCCESS = "fetch_success";
  if (state.status === SUCCESS) {
    state.data.length;
  }
}

// (5) as const なしの定数オブジェクトと比較 → 絞り込めない
const STATUS = { SUCCESS: "fetch_success", ERROR: "fetch_error" };
function e(state: State) {
  if (state.status === STATUS.SUCCESS) {
    state.data.length;
  }
}

// (6) as const つきの定数オブジェクトと比較 → 絞り込める
const STATUS2 = { SUCCESS: "fetch_success", ERROR: "fetch_error" } as const;
function f(state: State) {
  if (state.status === STATUS2.SUCCESS) {
    state.data.length;
  }
}

// (7) endsWith・includes → 絞り込めない
function g(state: State) {
  if (state.status.endsWith("success")) {
    state.data.length;
  }
  if (["fetch_success"].includes(state.status)) {
    state.data.length;
  }
}
e5-template.ts(15,11): error TS2339: Property 'data' does not exist on type 'State'.
  Property 'data' does not exist on type '{ status: "fetch_error"; message: string; }'.
e5-template.ts(38,11): error TS2339: Property 'data' does not exist on type 'State'.
  Property 'data' does not exist on type '{ status: "fetch_error"; message: string; }'.
e5-template.ts(53,11): error TS2339: Property 'data' does not exist on type 'State'.
  Property 'data' does not exist on type '{ status: "fetch_error"; message: string; }'.
e5-template.ts(56,11): error TS2339: Property 'data' does not exist on type 'State'.
  Property 'data' does not exist on type '{ status: "fetch_error"; message: string; }'.

共通しているのは「比べる相手の型が string になっていると絞り込めない」という点です。 埋め込みのあるテンプレートリテラルの式は、そのままだと string 型になります。as const を付けると "fetch_success" というリテラル型になり、絞り込めます。定数オブジェクトも同じで、as const を付けるとプロパティがリテラル型になります。endsWith や includes は実行時には正しく判定できますが、型の絞り込みにはつながりません。

判別子に使える型の一覧

判別子に使える型と、使えなかった型を1つずつ型チェックしました。

// e6-kinds.ts
// 文字列
type S = { tag: "a"; a: string } | { tag: "b"; b: number };
function s(v: S) { if (v.tag === "a") v.a; else v.b; }

// 数値
type N = { code: 200; body: string } | { code: 404; reason: string };
function n(v: N) { if (v.code === 200) v.body; else v.reason; }

// 真偽値
type B = { ok: true; value: string } | { ok: false; error: Error };
function b(v: B) { if (v.ok) v.value; else v.error; }

// null
type Nu = { error: null; value: string } | { error: Error };
function nu(v: Nu) { if (v.error === null) v.value; else v.error.message; }

// undefined
type U = { error: undefined; value: string } | { error: Error };
function u(v: U) { if (v.error === undefined) v.value; else v.error.message; }

// 省略可能なプロパティ
type O = { error?: undefined; value: string } | { error: Error };
function o(v: O) { if (v.error) v.error.message; else v.value; }

// bigint
type Bi = { id: 1n; one: string } | { id: 2n; two: string };
function bi(v: Bi) { if (v.id === 1n) v.one; else v.two; }

// enum
enum Kind { A, B }
type E = { kind: Kind.A; a: string } | { kind: Kind.B; b: number };
function e(v: E) { if (v.kind === Kind.A) v.a; else v.b; }

// リテラルのユニオン
type L = { tag: "a" | "b"; ab: string } | { tag: "c"; c: number };
function l(v: L) { if (v.tag === "c") v.c; else v.ab; }

// 一方が string(リテラルでない)
type X = { tag: "a"; a: string } | { tag: string; other: number };
function x(v: X) { if (v.tag === "a") v.a; }

// string と number(typeof で見分けようとする)
type T = { value: string; s: boolean } | { value: number; n: boolean };
function t(v: T) { if (typeof v.value === "string") v.s; }
e6-kinds.ts(40,41): error TS2339: Property 'a' does not exist on type 'X'.
  Property 'a' does not exist on type '{ tag: string; other: number; }'.
e6-kinds.ts(44,55): error TS2339: Property 's' does not exist on type 'T'.
  Property 's' does not exist on type '{ value: number; n: boolean; }'.
判別子の型 例 結果 向いている題材
文字列リテラル status: "loading" ○ 状態、アクション、イベントの種類
数値リテラル step: 1、code: 200 ○ ステップ番号、ステータスコード
真偽値リテラル ok: true ○ 成功か、そうでないかの2択
null error: null ○ 「エラーなし」を null で表す形
undefined error: undefined ○ プロパティはあるが値がない形
省略可能で undefined error?: undefined ○ 片方にだけプロパティがある形
bigint リテラル id: 1n ○ 大きな整数の識別子
enum のメンバー kind: Kind.A ○ enum をすでに使っているコード
リテラルのユニオン tag: "a" | "b" ○ 複数の値で同じ形を共有する
string(片方だけでも) tag: string × —
string と number を typeof で判定 typeof v.value === "string" × —

リテラル型・null・undefined のように「値が1つに決まる型」が判別子になります。 string や number のような広い型は、片方のメンバーに混ざっているだけでも絞り込めなくなります。

ケースを追加したときの網羅性エラー

判別可能なユニオン型の大きな利点は、メンバーを追加したときに、対応が必要な箇所をコンパイラが教えてくれることです。FetchState に "idle"(まだ何もしていない状態)を足し、switch は3つのままにして、4通りの書き方で確かめました。

// e7-exhaustive.ts
type FetchState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

// (A) default で never に代入
function renderA(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "読み込み中…";
    case "success":
      return `${state.data.length}件`;
    case "error":
      return state.message;
    default: {
      const unreachable: never = state;
      return unreachable;
    }
  }
}

// (B) 戻り値の型を書くだけ
function renderB(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "読み込み中…";
    case "success":
      return `${state.data.length}件`;
    case "error":
      return state.message;
  }
}

// (C) satisfies never
function renderC(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "読み込み中…";
    case "success":
      return `${state.data.length}件`;
    case "error":
      return state.message;
    default:
      throw new Error(`想定していない状態: ${(state satisfies never as FetchState).status}`);
  }
}

// (D) Record で対応表
const label: Record<FetchState["status"], string> = {
  loading: "読み込み中",
  success: "完了",
  error: "エラー",
};
e7-exhaustive.ts(17,13): error TS2322: Type '{ status: "idle"; }' is not assignable to type 'never'.
e7-exhaustive.ts(24,38): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
e7-exhaustive.ts(45,44): error TS1360: Type '{ status: "idle"; }' does not satisfy the expected type 'never'.
e7-exhaustive.ts(50,7): error TS2741: Property 'idle' is missing in type '{ loading: string; success: string; error: string; }' but required in type 'Record<"error" | "idle" | "loading" | "success", string>'.
書き方 エラー 足りないメンバーがエラー文に出るか 実行時の備え
(A) default で never に代入 TS2322 出る({ status: "idle"; }) default に throw を書ける
(B) 戻り値の型を書くだけ TS2366 出ない なし
(C) satisfies never TS1360 出る default に throw を書ける
(D) Record で対応表 TS2741 出る('idle') 値を返すだけの対応に向く

(B) は追加のコードがいらない手軽な方法ですが、どのケースが足りないかはエラー文に出ません。(A) の「default で never に代入」は足りないメンバーがそのまま表示されるので、メンバーが多い型ほど助けになります。どの方法でも、case "idle": を足す((D) なら idle: "未取得" を足す)とエラーは消えます。

よくある質問

Q. 判別子の名前は type・kind・status のどれがよいですか? A. どれでも動作は同じです。アクションやイベントなら type、図形や部品の種類なら kind、状態なら status、成功かどうかの2択なら ok がよく使われます。1つの型定義の中で名前をそろえておくことが大切です。

Q. switch と if のどちらで書くとよいですか? A. 絞り込みの働きは同じです。メンバーが3つ以上あるなら switch のほうがケースの並びが見やすく、網羅性の確認も書きやすくなります。ok: true / false のような2択は if と else で十分です。

Q. 判別子を持たないメンバーが混ざっていても使えますか? A. 片方にだけプロパティがある形でも、{ error?: undefined; value: string } | { error: Error } のように、無い側に「省略可能で undefined」と書いておくと絞り込めます(判別子に使える型の一覧の O の例)。

Q. 外部から受け取った JSON を判別可能なユニオン型として扱えますか? A. 型の絞り込みはコンパイル時の仕組みなので、実行時に届いた値が本当にその形かどうかは別に確かめる必要があります。受け取った直後に判別子の値を確認する関数を通し、そのあとで switch に渡す流れにすると、型と実際の値がそろいます。

まとめ

  • 判別可能なユニオン型は、全メンバーに同じ名前の判別子をリテラル型で持たせたユニオン型で、switch や if で比べるだけで残りのプロパティまで絞り込める
  • 読み込み状態は status、API レスポンスは ok、reducer のアクションは type、フォームは step のように、題材に合った判別子を選ぶ
  • 判別子はオブジェクトの直下に置き、=== か switch で直接比べると、どの書き方でも絞り込める
  • 分割代入と別変数は const で1つの文にまとめれば絞り込める。let、文の分割、残余プロパティ、boolean を返す関数では TS2339 になる
  • 比べる相手が string 型だと絞り込めない。テンプレートリテラルや定数オブジェクトには as const を付ける
  • 判別子に使えるのは文字列・数値・真偽値・bigint のリテラル、null、undefined、enum のメンバー
  • default で never に代入しておくと、メンバー追加時に TS2322 で足りないケースが表示される

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript 判別可能なユニオン型の使い方|switchで絞り込む」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?