TypeScriptのUnion、Intersection、Narrowing

1. 概要

UnionとIntersectionは、TypeScriptがプレーンなinterfaceより柔軟にデータを説明するための2つの基本的なツールです。Narrowingは、コンパイラが各コード分岐で値の具体的な型を「理解」するのに役立つ技術であり、手動で型を強制する(as)必要がありません。

このガイドは、union/intersectionの概念から、型を絞るための一般的なテクニック(typeof、instanceof、in、discriminated union)を経て、カスタム型ガード(type guard)の書き方まで説明します。読み終わったら、unionをいつ使うのか、intersectionをいつ使うのか、そしてObject is possibly undefinedやas anyがコード全体に蔓延する状況を回避する方法がわかるでしょう。

2. Union型

Union(|)は、値が複数の型のいずれかであることを意味します。

type PaymentMethod = "cash" | "card" | "e-wallet";

function describe(method: PaymentMethod): string {
  return `支払い方法: ${method}`;
}

describe("card"); // OK
// describe("paypal"); // コンパイルエラー: unionに含まれていない

PaymentMethodは3つの具体的な文字列リテラルのみを受け付けます。異なる値を渡すと、TypeScriptはランタイムを待たずにコンパイル時に即座にエラーを報告します。

Unionはオブジェクト型にも適用できます:

interface CashPayment {
  type: "cash";
  amount: number;
}

interface CardPayment {
  type: "card";
  amount: number;
  cardNumber: string;
}

type Payment = CashPayment | CardPayment;

この場合、Payment型の変数はCashPaymentまたはCardPaymentのいずれかですが、型を絞る(narrowing)前は、TypeScriptは両方に共通するフィールド(この場合はtypeとamount)のみにアクセスできます。

function printAmount(p: Payment) {
  console.log(p.amount); // OK、共通フィールド
  // console.log(p.cardNumber); // エラー: CashPaymentには存在しない
}

注意: Unionはフィールドをマージしません。プライベートフィールドにアクセスするには、まず型を絞る必要があります(セクション4を参照)。

3. Intersection型

Intersection(&)は、値が結合されたすべての型を同時に満たすことを意味します。

interface Identifiable {
  id: string;
}

interface Timestamped {
  createdAt: Date;
}

type Order = Identifiable & Timestamped & {
  customerName: string;
  total: number;
};

const order: Order = {
  id: "ORD-001",
  createdAt: new Date(),
  customerName: "Nguyễn Văn A",
  total: 150_000,
};

OrderはここでIdentifiable、Timestamped、および追加されたオブジェクトリテラルのすべてのフィールドを持ちます。フィールドが1つでも欠けている場合、コンパイラはエラーを報告します。

Intersectionは、OOPのcompositionに似た方法で複数の小さなmixinを1つの大きな型に「ブレンド」したい場合に便利です。ただし、型レベルでの結合です。

type AuditInfo = { updatedBy: string };

type AuditedOrder = Order & AuditInfo;

const audited: AuditedOrder = {
  ...order,
  updatedBy: "admin",
};

3.1. 矛盾するプリミティブ型とのIntersection

2つの型がIntersectionで衝突する場合、結果はneverになります:

type Impossible = string & number; // never型 — これを満たす値は存在しない

これは通常、&を|と誤入力した場合の兆候です。

4. Narrowing: 各コード分岐で型を絞る

Narrowingは、コンパイラが条件チェック(if、switch、typeofなど)に基づいてより具体的な型を推測するプロセスです。

4.1. typeofでのNarrowing

プリミティブ型のunionに使用します。

function formatId(id: string | number): string {
  if (typeof id === "number") {
    return id.toFixed(0); // id はnumber型に絞られた
  }
  return id.toUpperCase(); // id はstring型に絞られた
}

ifブロック内で、TypeScriptはidがnumberであることを認識しているため、.toFixed()を呼び出せます。elseブロック(暗黙的)では、idはstringのみとなります。

4.2. instanceofでのNarrowing

classのunionに使用します。

class ValidationError extends Error {
  constructor(public field: string) {
    super(`Field ${field} is invalid`);
  }
}

function handleError(err: Error | ValidationError): void {
  if (err instanceof ValidationError) {
    console.log(`バリデーションエラー フィールド: ${err.field}`);
  } else {
    console.log(`その他のエラー: ${err.message}`);
  }
}

err.fieldはValidationErrorにのみ存在するため、コンパイラはチェック済みのブロック内でのみアクセスを許可します。

4.3. inでのNarrowing

union内の型が個別のフィールドで区別される場合に使用します(共通のdiscriminantフィールドがない場合)。

interface Dog {
  bark: () => void;
}

interface Cat {
  meow: () => void;
}

function makeSound(pet: Dog | Cat): void {
  if ("bark" in pet) {
    pet.bark(); // Dog型に絞られた
  } else {
    pet.meow(); // Cat型に絞られた
  }
}

in演算子はランタイムで対象のプロパティが存在するかをチェックし、コンパイラはその結果を使用して対応する型に絞ります。

4.4. Discriminated Union — もっとも安全なアプローチ

これは実際のコードで最も一般的なパターンです: 共通のリテラルフィールド(kindまたはtypeとよく呼ばれます)を追加して、union内の「ブランチ」を区別します。

interface SuccessResult {
  status: "success";
  data: string;
}

interface ErrorResult {
  status: "error";
  message: string;
}

type ApiResult = SuccessResult | ErrorResult;

function handleResult(result: ApiResult): void {
  switch (result.status) {
    case "success":
      console.log(`データ: ${result.data}`);
      break;
    case "error":
      console.log(`エラー: ${result.message}`);
      break;
  }
}

コンパイラはstatusフィールドを「discriminant」として使用します: case "success"ではresultはSuccessResultに絞られ、case "error"ではErrorResultに絞られます。これはTypeScriptが複数のブランチを持つunionに推奨する方法です。なぜならswitchはexhaustiveness(網羅性)チェックもサポートするからです(セクション4.6を参照)。

4.5. Truthinessと optional channingでのNarrowing

interface Customer {
  name: string;
  phone?: string;
}

function callCustomer(customer: Customer): void {
  if (customer.phone) {
    // customer.phone は string | undefined から string に絞られた
    console.log(`電話番号で連絡: ${customer.phone}`);
  } else {
    console.log("顧客は電話番号を持っていません");
  }
}

truthy値のチェック(if (customer.phone))はundefinedを除外します(そして空の文字列も除外されますが、電話番号の場合はそこまで気にしなくても良いでしょう)。

4.6. neverでのExhaustiveness Check

unionに新しいブランチを追加したが処理を忘れた場合、ランタイムのバグをこっそり導入する代わりに、コンパイル時にコンパイラがエラーを報告することを望みます。defaultブランチでnever型を使用します:

function assertUnreachable(x: never): never {
  throw new Error(`到達してはいけないケース: ${JSON.stringify(x)}`);
}

function handleResultSafe(result: ApiResult): string {
  switch (result.status) {
    case "success":
      return result.data;
    case "error":
      return result.message;
    default:
      return assertUnreachable(result); // 新しいブランチを追加して対応を忘れた場合、ここでコンパイルエラー
  }
}

後で誰かがPendingResultをApiResultに追加して、対応するcaseを追加し忘れた場合、defaultブランチのresultはneverではなくなり、コンパイラはビルド時にエラーを報告します — productionに到達するバグを待つ必要がありません。

5. ユーザー定義型ガード

チェックロジックが単純な式よりも複雑な場合、narrowingを再利用するためにx is Tを返す関数を書くことができます。

interface PaidOrder {
  status: "paid";
  paidAt: Date;
}

interface PendingOrder {
  status: "pending";
}

type OrderState = PaidOrder | PendingOrder;

function isPaid(order: OrderState): order is PaidOrder {
  return order.status === "paid";
}

function printReceipt(order: OrderState): void {
  if (isPaid(order)) {
    console.log(`支払日時: ${order.paidAt.toISOString()}`);
  } else {
    console.log("まだ支払われていません");
  }
}

order is PaidOrderは型述語(type predicate)です。コンパイラはisPaid関数の結果を信頼して、チェックロジックが別の関数にある場合でも、ifブロック内のorderをPaidOrderに絞ります。

6. テストで検証する

Narrowingはコンパイル時の機能なので、「テスト」は実際にはtscを実行してエラーがないことを確認することです。ただし、対応するランタイム動作のテストも書くべきです。

npm install -D vitest typescript
import { describe, it, expect } from "vitest";

function totalAmount(payment: CashPayment | CardPayment): number {
  return payment.amount;
}

describe("Payment narrowing", () => {
  it("現金支払いの金額を正しく計算", () => {
    const cash: CashPayment = { type: "cash", amount: 50_000 };
    expect(totalAmount(cash)).toBe(50_000);
  });

  it("カード支払いの金額を正しく計算", () => {
    const card: CardPayment = {
      type: "card",
      amount: 200_000,
      cardNumber: "4111111111111111",
    };
    expect(totalAmount(card)).toBe(200_000);
  });
});

npx vitest runを実行すると:

✓ Payment narrowing > 現金支払いの金額を正しく計算
✓ Payment narrowing > カード支払いの金額を正しく計算

Test Files  1 passed (1)
     Tests  2 passed (2)

7. よくあるエラーと非推奨な使用方法

  • narrowingを避けるためにasを濫用する: コンパイラを「満足させる」ために継続的に型を強制(as SomeType)しているなら、unionや型ガードの設計が間違っている可能性が高いです。discriminated unionに戻ることを検討してください。
  • 同じフィールド名だが異なる型を持つ2つのinterfaceのIntersection: TypeScriptは統合しようとしますが、型が衝突する場合(例えば一方がstring、もう一方がnumber)、そのフィールドはneverになり、理解しづらいエラーが発生します。mixinを設計するときは常にフィールド名を統一してください。
  • 非常に広いunion(例:string | number | boolean | object): 型を使用するすべての場所でnarro化が必要になり、コードが混乱します。unionが4~5個以上の無関係なブランチを持つ場合、ドメインモデルを見直してください。複数の独立した関数/overloadに分割する方が良いかもしれません。
  • switchでdefaultとneverを忘れる: exhaustiveness checkの利点を失い、unionを拡張するときにブランチを見落としやすくなります。
  • 複雑なprototypeチェーンを持つオブジェクトでin演算子を使用: inは継承されたプロパティもチェックするため、深い継承を持つclassではnarro化の結果が期待通りにならないことがあります。その場合はinstanceofまたはdiscriminantフィールドがより信頼性が高いです。

注意: Narrowingは、TypeScriptが静的な制御フローで証明できる場合にのみ適切に機能します。既に絞られた値を別の変数に割り当ててから、非同期コールバック(setTimeout、promise.then)を介して渡す場合、絞られた型は「忘れられる」可能性があります。TypeScriptはすべての状況でclojureの境界を越えて追跡するわけではありません。

8. 結論

Unionは「複数の型の1つ」を説明し、Intersectionは「すべての型を同時に」説明します — この2つのツールはTypeScriptで正確なドメインモデルを構築するための基礎です。Narrowing(typeof、instanceof、in、discriminated union、カスタム型ガード)を使用すれば、手動の型強制なしにunionを安全に活用できます。

実践的なルール: 複数のブランチを持つ場合はdiscriminated unionを優先し、switchでのneverによるexhaustiveness checkを常に追加し、同じ名前のフィールドで型が異なるintersectionを避けてください。正しく適用すれば、productionに到達する前にコードを書いている段階で多くの論理エラーをキャッチできます。

typescripttype-safetyjavascriptfrontend