swan's room
Blog

TypeScriptの型システム深掘り — Union・Intersection・TypeGuard・関数オーバーロードの使い所と落とし穴

TypeScriptの基本的な型は分かるけれど、実際に使おうとすると手が止まる場面があります。どの型ガードを選ぶべきか、関数オーバーロードとジェネリクスのどちらが正解か——そういった判断の迷いが出てくる段階の方向けに書きました。

公式ドキュメントを読んだあとの「で、実際どう使い分けるの?」に答えることを目指しています。各概念の「使い所」と「使うべきでないケース」を並べて整理しました。

ユニオン型 — 「どちらか」と、その先にある never

ユニオン型の概念図:A型またはB型を表すVenn図

基本は「A型かB型」を | で表現する、シンプルな仕組みです。

type ApiResponse = SuccessResponse | ErrorResponse;

ただ、ユニオン型の本当の価値は少し先にあります。網羅性チェックとの組み合わせです。

never で網羅漏れをコンパイルエラーにする

ディスクリミネイテッドユニオン(後述)と never を組み合わせると、ケースを追加したときの対応漏れをビルド時に検知できます。

type Shape =
  | { kind: 'circle';   radius: number }
  | { kind: 'square';   side: number }
  | { kind: 'triangle'; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':   return Math.PI * shape.radius ** 2;
    case 'square':   return shape.side ** 2;
    case 'triangle': return (shape.base * shape.height) / 2;
    default:
      // ここに到達したら型エラー — 全ケースを処理していない証拠
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Shaperectangle を追加したとき、default ブロックで型エラーが出ます。テストを書かなくても型システムが網羅漏れを教えてくれるので、これを知ってからユニオン型の使い方がかなり変わりました。

ユニオン型を使うべきでないケース

全ての型が同じプロパティを持っている場合は、ユニオン型より共通インターフェースの方が素直です。

// ❌ 全種類が id と name を持つのに、ユニオンにしている
type Entity = User | Product | Order;

// ✅ 共通部分はインターフェースに切り出す
interface Identifiable { id: string; name: string; }
// User, Product, Order それぞれが Identifiable を実装する

インターセクション型 — 「両方」と、型の衝突

インターセクション型の概念図:A型とB型の積集合

& で複数の型を結合すると、全プロパティを持つ新しい型を作れます。

type AdminUser = BasicUser & AdminPrivileges;

既存の型に新しいプロパティを追加したい場面や、複数のインターフェースをひとつの型で表現したい場面で使います。

プロパティが衝突したときの挙動

同名プロパティで型が異なると、そのプロパティの型が never になります。これが地味な罠で、実務でも何度かハマりました。

type A = { value: string };
type B = { value: number };
type C = A & B;
// C['value'] は never — string かつ number を満たす値は存在しない

const x: C = { value: 'hello' }; // エラー: string は never に代入不可
const y: C = { value: 123 };     // エラー: number は never に代入不可

& SomeExternalType を使って突然 never になるエラーで詰まるときは、だいたいこれが原因です。型の合成前にプロパティが重複していないか確認する習慣をつけると良いです。

インターセクション型を使うべきでないケース

extends で継承できる場合は、interface extends の方が可読性が高いです。

// ❌ 読みづらい
type AdminUser = BasicUser & { roles: string[] };

// ✅ extends の方が意図が伝わりやすい
interface AdminUser extends BasicUser { roles: string[]; }

ただし、既存の type を組み合わせる場合(ライブラリの型に追加するときなど)はインターセクション一択になります。

型ガード — typeof / in / instanceof の使い分け

型ガードのフロー:Union型からstring/number/booleanに絞り込む様子

型ガードには3種類の組み込みと、カスタム型ガードがあります。使い分けを雑にすると絞り込みが意図通りに動かないので、それぞれの用途を整理しておきます。

typeof — プリミティブ型の判別

function format(value: string | number | boolean) {
  if (typeof value === 'string')  return value.toUpperCase();
  if (typeof value === 'number')  return value.toFixed(2);
  return value ? 'yes' : 'no';
}

落とし穴: typeof null === 'object' になります。JavaScriptの歴史的なバグです。null を含むユニオン型では、typeof チェックの前に === null を先に確認する必要があります。

function process(value: string | null) {
  if (value === null) return 'empty'; // typeof の前に null チェック
  return value.toUpperCase();
}

in 演算子 — オブジェクトの型判別(実務で最頻出)

プロパティの有無でオブジェクトの型を判別します。APIレスポンスの処理など、実務では typeofinstanceof よりも使う頻度が高いです。

type ApiSuccess = { data: User[];  total: number  };
type ApiError   = { error: string; code: number   };
type ApiResult  = ApiSuccess | ApiError;

function handleResult(result: ApiResult) {
  if ('data' in result) {
    // ApiSuccess に絞り込まれます
    console.log(result.data, result.total);
  } else {
    // ApiError に絞り込まれます
    console.error(result.error, result.code);
  }
}

落とし穴: in の対象がオブジェクトでないと実行時エラーになります。nullundefined の可能性がある場合は先にチェックしておきましょう。

// ❌ result が null だと TypeError
if ('data' in result) { ... }

// ✅
if (result !== null && 'data' in result) { ... }

instanceof — クラスインスタンスの判別

クラスや組み込みオブジェクト(DateError など)の判別に使います。

function formatDate(value: string | Date) {
  if (value instanceof Date) return value.toISOString();
  return new Date(value).toISOString();
}

プレーンオブジェクトには使えない点に注意が必要です。instanceof が有効なのはクラスのインスタンス判別に限られます。

カスタム型ガード — is 型述語

組み込み型ガードで判別できない複雑な型には、pet is Fish 形式の型述語を使います。

// API レスポンスの型を判別するカスタム型ガード
function isApiSuccess(result: ApiResult): result is ApiSuccess {
  return 'data' in result && Array.isArray(result.data);
}

注意点: カスタム型ガードは戻り値が true であっても、TypeScript はその実装の正しさを検証しません。return true と書いてもコンパイルエラーにはならないので、型の安全性は開発者の責任になります。

// ❌ これはコンパイルが通ってしまいます(実行時に壊れます)
function isString(v: unknown): v is string {
  return true; // 常に true — 型システムを騙している
}

ディスクリミネイテッドユニオン — switch で型が自動的に絞り込まれる

ディスクリミネイテッドユニオン:statusプロパティによるswitch分岐の図

共通のリテラル型プロパティ(判別子)を持つユニオン型で、switchif-else で判別子の値をチェックするだけで型が自動的に絞り込まれます。

type SuccessState = { status: 'success'; data: string[] };
type LoadingState = { status: 'loading' };
type ErrorState   = { status: 'error';  message: string };
type FetchState   = SuccessState | LoadingState | ErrorState;

function handleFetchState(state: FetchState) {
  switch (state.status) {
    case 'success': console.log(state.data);     break; // SuccessState
    case 'loading': console.log('Loading...');  break; // LoadingState
    case 'error':   console.error(state.message); break; // ErrorState
  }
}

カスタム型ガード関数を書かずに済むのが最大のメリットです。Redux のアクション型や非同期処理の状態管理で頻出するパターンです。

冒頭で紹介した never による網羅性チェックと組み合わせると、ケース追加時の対応漏れをコンパイル時に検知できます。

判別子に使えるのはリテラル型のみ

判別子プロパティの型はリテラル型('success''error' など)でなければなりません。string 型では絞り込みが機能しないので注意が必要です。

// ❌ 判別子が string 型 — 絞り込みできない
type Bad = { type: string; data: string } | { type: string; error: string };

// ✅ 判別子がリテラル型 — 絞り込みが機能する
type Good = { type: 'data'; data: string } | { type: 'error'; error: string };

関数オーバーロード vs ジェネリクス — どちらを選ぶか

関数オーバーロード:引数の型によって異なる戻り値型を持つ関数の概念図

関数オーバーロードとジェネリクスはどちらも「引数の型によって戻り値の型が変わる」ことを表現できます。最初はこの使い分けで1時間近く迷いました。

// オーバーロード版
function wrap(value: string): string[];
function wrap(value: number): number[];
function wrap(value: string | number): string[] | number[] {
  return [value as any]; // 実装シグネチャでは as any が必要
}

// ジェネリクス版
function wrap<T extends string | number>(value: T): T[] {
  return [value]; // キャスト不要
}

ジェネリクスで書けるなら、ジェネリクスを選ぶ

上の例のように「入力型と出力型に一定の関係がある」場合は、ジェネリクスの方がシンプルです。オーバーロードは呼び出しシグネチャの数だけ定義が増え、実装シグネチャ(外部から呼べない)との二重管理が必要になります。

もうひとつ見落としがちな点があります。オーバーロードの実装シグネチャでは、TypeScript が型を十分に絞り込めないため as any のようなキャストが必要になることがあります。このキャストは型チェックを迂回するので、実装を誤っても型エラーが出ません。ジェネリクス版では T が伝播するためキャスト不要で、型安全性が保たれます。ジェネリクスで表現できる関数にオーバーロードを選ぶべきでない理由のひとつです。

オーバーロードが有効なケース

引数の組み合わせで返り値の型が複雑に変わる場合、ジェネリクスでは表現しきれないことがあります。

// 引数が1つなら 0 から、2つなら start から始まる配列を返す
function range(end: number): number[];
function range(start: number, end: number): number[];
function range(startOrEnd: number, end?: number): number[] {
  const start = end === undefined ? 0 : startOrEnd;
  const actualEnd = end ?? startOrEnd;
  return Array.from({ length: actualEnd - start }, (_, i) => start + i);
}

引数の数によって意味が変わる関数は、ジェネリクスでは素直に書けません。このような場合はオーバーロードが適しています。

実装シグネチャは外部から呼べない

オーバーロードで詰まりやすいのがこのルールです。呼び出しシグネチャのどれかに合致しない呼び出しは、実装シグネチャがどれだけ緩い型であっても弾かれます。

function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: any, b: any): any { return a + b; }

add(1, 2);     // ✅ number + number
add('a', 'b'); // ✅ string + string
add(1, 'b');   // ❌ 呼び出しシグネチャに合致しない

「なるほど」ポイント4つ

  1. never で網羅性チェックdefault: const x: never = value を書いておくと、ユニオン型に新しいケースを追加したときの対応漏れをコンパイルエラーで検知できます
  2. 型ガードの実務頻度は in が高いtypeof はプリミティブ、instanceof はクラス、in はプレーンオブジェクトの型判別に使います。typeof null === 'object' は必ず頭に入れておきましょう
  3. カスタム型ガードは型システムを信用できないreturn true でもコンパイルが通ってしまいます。実装の正しさは自分で保証する必要があります
  4. 関数オーバーロードはジェネリクスで書けないときの最終手段 — 入力と出力に一定の関係があればジェネリクス、引数の組み合わせで型が複雑に変わる場合はオーバーロードが適しています。オーバーロードの実装シグネチャでは as any が必要になることがあり、型安全性の穴になりえます
Share
Share

S
Swan

ソフトウェアエンジニア。フロントエンドを中心に日々の学びを記録しています。