TypeScript 速習チュートリアル

TypeScript 宣言の併合

1. Declaration Merging(宣言の併合)の理解

Declaration Merging(宣言の併合)は、同じ名前を持つ複数の宣言を一つの定義に結合する TypeScript の強力な機能です。 これにより、複雑な型を段階的に構築したり、既存の型を型安全(Type-safe)な方法で拡張したりすることが可能になります。

1.1 主なメリット

  • 漸進的な強化(Progressive Enhancement): 複数の宣言にわたって型を段階的に構築できます。
  • 拡張性(Extensibility): 元の定義を修正することなく、既存の型に新しいメンバーを追加できます。
  • コードの整理(Organization): 巨大な型定義を論理的なグループに分割して管理できます。
  • 互換性(Compatibility): 必要に応じてサードパーティの型定義を拡張できます。

1.2 一般的なユースケース

  • ビルトイン型やサードパーティライブラリの型拡張
  • JavaScript ライブラリへの型情報の追加
  • 複数のファイルにわたる大規模なインターフェースの整理
  • メソッドチェーンを利用した流れるようなインターフェース(Fluent API)の実装
  • モジュール拡張(Module Augmentation)パターンの実装

2. インターフェースの併合(Interface Merging)

同じ名前のインターフェースは自動的に併合されます。

コード例

// 最初の宣言
interface Person {
  name: string;
  age: number;
}

// 同名での2番目の宣言
interface Person {
  address: string;
  email: string;
}

// TypeScript はこれらを以下のように併合します:
// interface Person {
//   name: string;
//   age: number;
//   address: string;
//   email: string;
// }

const person: Person = {
  name: "John",
  age: 30,
  address: "123 Main St",
  email: "[email protected]"
};

console.log(person);

3. 併合による関数のオーバーロード

複数の関数宣言を定義し、後に実装時にそれらを併合させることができます。

コード例

// 関数のオーバーロード(Overloads)
function processValue(value: string): string;
function processValue(value: number): number;
function processValue(value: boolean): boolean;

// すべてのオーバーロードを処理する実装
function processValue(value: string | number | boolean): string | number | boolean {
  if (typeof value === "string") {
    return value.toUpperCase();
  } else if (typeof value === "number") {
    return value * 2;
  } else {
    return !value;
  }
}

// 異なる型での関数利用
console.log(processValue("hello")); // "HELLO"
console.log(processValue(10));      // 20
console.log(processValue(true));    // false

4. 名前空間の併合(Namespace Merging)

同じ名前を持つ名前空間(Namespace)も併合されます。

コード例

namespace Validation {
  export interface StringValidator {
    isValid(s: string): boolean;
  }
}

namespace Validation {
  export interface NumberValidator {
    isValid(n: number): boolean;
  }

  export class ZipCodeValidator implements StringValidator {
    isValid(s: string): boolean {
      return s.length === 5 && /^\d+$/.test(s);
    }
  }
}

// 併合後:
// namespace Validation {
//   export interface StringValidator { isValid(s: string): boolean; }
//   export interface NumberValidator { isValid(n: number): boolean; }
//   export class ZipCodeValidator implements StringValidator { ... }
// }

// 併合された名前空間の使用
const zipValidator = new Validation.ZipCodeValidator();

console.log(zipValidator.isValid("12345")); // true
console.log(zipValidator.isValid("1234"));  // false
console.log(zipValidator.isValid("abcde")); // false

5. クラスとインターフェースの併合

クラス宣言は、同じ名前のインターフェースと併合することができます。

コード例

// インターフェース宣言
interface Cart {
  calculateTotal(): number;
}

// 同名のクラス宣言
class Cart {
  items: { name: string; price: number }[] = [];

  addItem(name: string, price: number): void {
    this.items.push({ name, price });
  }

  // インターフェースのメソッドを実装する必要がある
  calculateTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

// 併合されたクラスとインターフェースの使用
const cart = new Cart();
cart.addItem("Book", 15.99);
cart.addItem("Coffee Mug", 8.99);

console.log(`Total: $${cart.calculateTotal().toFixed(2)}`);

6. Enum の併合

同じ名前の Enum(列挙型)宣言は併合されます。

コード例

// Enum の最初の部分
enum Direction {
  North,
  South
}

// Enum の2番目の部分
enum Direction {
  East = 2,
  West = 3
}

// 併合後:
// enum Direction {
//   North = 0,
//   South = 1,
//   East = 2,
//   West = 3
// }

console.log(Direction.North); // 0
console.log(Direction.South); // 1
console.log(Direction.East);  // 2
console.log(Direction.West);  // 3

// 値によるアクセスも可能
console.log(Direction[0]); // "North"
console.log(Direction[2]); // "East"

7. モジュール拡張(Module Augmentation)

追加の型や機能を宣言することで、既存のモジュールやライブラリを拡張できます。

コード例

// 元のライブラリ定義
// これがサードパーティライブラリから提供されていると仮定します
declare namespace LibraryModule {
  export interface User {
    id: number;
    name: string;
  }
  export function getUser(id: number): User;
}

// 追加機能による拡張(自分のコード)
declare namespace LibraryModule {
  // 新しいインターフェースを追加
  export interface UserPreferences {
    theme: string;
    notifications: boolean;
  }

  // 既存のインターフェースに新しいプロパティを追加
  export interface User {
    preferences?: UserPreferences;
  }

  // 新しい関数を追加
  export function getUserPreferences(userId: number): UserPreferences;
}

// 拡張されたモジュールの使用
const user = LibraryModule.getUser(123);
console.log(user.preferences?.theme);

const prefs = LibraryModule.getUserPreferences(123);
console.log(prefs.notifications);

8. ベストプラクティス

Declaration Merging を使用する際には、いくつか考慮すべきルールがあります。

  • オーバーロードの順序: 関数のオーバーロードでは順序が重要です。実装のシグネチャ(Signature)は最も汎用的なものである必要があります。
  • 非関数メンバーの互換性: 2つのインターフェースが同名のプロパティを宣言する場合、それらの型は同一または互換性がなければなりません。
  • 後から定義されたインターフェースの優先順位: 併合されたインターフェース内で競合が発生した場合、基本的には後の宣言が優先されます。
  • Private および Protected メンバー: クラスが同じ名前の private または protected メンバーを持ち、それらの型が異なる場合、併合はできません。
  • 名前空間のエクスポート: 併合後、名前空間の外側からは export された宣言のみが可視となります。

9. パフォーマンスに関する考慮事項

  • コンパイル時間: 過度な Declaration Merging は、コンパイル時間を増大させる可能性があります。
  • 型チェック: 複雑に併合された型は、IDE のパフォーマンスに影響を与える場合があります。
  • バンドルサイズ: Declaration Merging はコンパイル時の概念であるため、実行時のパフォーマンスやバンドルサイズには影響しません。

9.1 最適化のヒント

  • 併合するインターフェースは焦点が絞られ、まとまりのある(Cohesive)状態に保ちます。
  • 併合された型の中での深いネストは避けます。
  • 単純な型の組み合わせであれば、マージではなく型エイリアス(Type Alias)の使用を検討してください。