T-CREATOR

TypeScript 公開 API の型設計術:`export type`/`interface`/`class`の責務分担と境界設計

TypeScript 公開 API の型設計術:`export type`/`interface`/`class`の責務分担と境界設計

TypeScript でライブラリやパッケージを開発する際、公開 API の型設計は非常に重要です。利用者にとって使いやすく、保守しやすいコードを提供するには、export typeinterfaceclass をどのように使い分けるべきか悩むことも多いでしょう。

本記事では、TypeScript の公開 API における型設計の基本から実践的なテクニックまでを解説します。それぞれの型定義方法の責務を明確にし、境界設計の考え方を学ぶことで、拡張性と保守性を兼ね備えた API を設計できるようになるでしょう。

背景

TypeScript における型定義の選択肢

TypeScript で外部に公開する API を設計する際、主に以下の 3 つの方法で型を定義できます。

#定義方法主な用途特徴
1type型エイリアス、ユニオン型、複雑な型の定義柔軟性が高く、型演算が可能
2interfaceオブジェクトの形状定義、拡張可能な契約宣言マージが可能、継承しやすい
3classデータと振る舞いを持つ実体インスタンス化可能、実装を含む

それぞれの選択肢には明確な設計思想があり、適切に使い分けることで型安全性と可読性を両立できます。

以下の図は、TypeScript における型定義の全体像を示しています。

mermaidflowchart TB
  api["公開 API 設計"]
  api --> type_alias["type<br/>型エイリアス"]
  api --> interface_def["interface<br/>契約定義"]
  api --> class_def["class<br/>実装+型"]

  type_alias --> type_use["ユニオン型<br/>交差型<br/>複雑な型演算"]
  interface_def --> interface_use["オブジェクト形状<br/>拡張可能な契約<br/>宣言マージ"]
  class_def --> class_use["データ+振る舞い<br/>インスタンス化<br/>実装の提供"]

図で理解できる要点:

  • 公開 API 設計には 3 つの主要な型定義方法がある
  • それぞれ異なる用途と特徴を持つ
  • 適切な使い分けが重要

公開 API における型設計の重要性

ライブラリやパッケージの公開 API は、利用者との「契約」です。この契約が曖昧だと、利用者は API の使い方を誤解し、予期しないバグを引き起こす可能性があります。

型設計が適切であれば、以下のメリットが得られます。

  • 型安全性の向上: コンパイル時にエラーを検出できる
  • IDE のサポート強化: 自動補完やリファクタリングが容易になる
  • ドキュメント性の向上: 型定義自体がドキュメントとして機能する
  • 保守性の向上: 将来の変更や拡張がしやすくなる

一方で、型設計が不適切だと、利用者に余計な型アサーションを強いたり、型定義の意図が伝わりにくくなったりします。

課題

typeinterfaceclass の使い分けの難しさ

多くの開発者が直面する課題は、「いつ type を使い、いつ interface を使い、いつ class を使うべきか」という判断です。

以下のような状況で迷うことが多いでしょう。

#状況迷いやすいポイント
1オブジェクトの形状を定義したいtypeinterface のどちらを選ぶべきか
2将来的に拡張可能にしたいinterface の宣言マージか type の交差型か
3データと振る舞いをセットで提供したいclass を使うべきか、関数と type で十分か
4複雑な型を表現したいtype の型演算を使うべきか、interface で分割すべきか

公開 API における境界設計の曖昧さ

公開 API の設計では、「どこまでを公開し、どこまでを内部実装にするか」という境界設計も重要です。

以下のような図は、公開 API と内部実装の境界を示しています。

mermaidflowchart LR
  user["利用者のコード"]
  public_api["公開 API<br/>(export)"]
  internal["内部実装<br/>(非 export)"]

  user -->|使用| public_api
  public_api -.->|依存| internal
  user -.->|アクセス不可| internal

  style public_api fill:#e1f5e1
  style internal fill:#f5e1e1

図で理解できる要点:

  • 公開 API と内部実装の境界を明確にする
  • 利用者は公開 API のみにアクセス
  • 内部実装は変更の自由度を保つ

境界設計が曖昧だと、以下の問題が発生します。

  • 内部実装の詳細が漏れ出し、将来の変更が困難になる
  • 利用者が内部実装に依存してしまい、互換性が失われる
  • API の意図が不明確になり、誤用が増える

型の拡張性と互換性のバランス

公開 API を設計する際、「拡張性」と「互換性」のバランスも重要な課題です。

拡張性を重視しすぎると、型定義が複雑になり、利用者にとって理解しにくくなります。一方で、互換性を重視しすぎると、将来の機能追加が困難になります。

解決策

export type の責務:純粋な型情報の提供

export type は、純粋な型情報のみを提供する場合に使用します。ランタイムでは何も生成されず、コンパイル時の型チェックにのみ使用されます。

export type を使うべき場面

以下の表は、export type を使うべき場面をまとめたものです。

#使用場面理由
1ユニオン型やリテラル型の定義型演算が必要な場合
2関数の引数や戻り値の型振る舞いを持たない純粋な型情報
3複雑な型の合成交差型、条件型などを活用
4型のエイリアス既存の型に別名を付ける

基本的な export type の使い方

まず、シンプルなユニオン型の定義から見てみましょう。

typescript// ステータスを表すリテラル型のユニオン
export type Status = 'pending' | 'approved' | 'rejected';

この Status 型は、3 つの文字列リテラルのいずれかを取ることを示しています。これにより、利用者は型安全にステータスを扱えます。

次に、オプション設定のための型エイリアスを定義します。

typescript// 設定オプションの型定義
export type Options = {
  timeout?: number; // タイムアウト時間(ミリ秒)
  retryCount?: number; // リトライ回数
  debug?: boolean; // デバッグモードの有効化
};

この Options 型は、すべてのプロパティがオプショナルであることを示しています。利用者は必要なオプションのみを指定できます。

複雑な型演算の例

type を使うと、複雑な型演算を行うことができます。以下は、型から特定のプロパティを抽出する例です。

typescript// ユーザー情報の完全な型
type User = {
  id: number;
  name: string;
  email: string;
  password: string;
  role: 'admin' | 'user';
};

この User 型から、パスワードを除いた公開可能な情報のみを抽出する型を作成します。

typescript// パスワードを除いた公開用のユーザー型
export type PublicUser = Omit<User, 'password'>;

Omit ユーティリティ型を使うことで、password プロパティを除外した新しい型を生成できます。

さらに、条件型を使った高度な型演算も可能です。

typescript// API レスポンスの型(成功時と失敗時で異なる)
export type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string };

この ApiResponse 型は、成功時と失敗時で異なる形状を持つユニオン型です。success プロパティで型を絞り込むことができます。

interface の責務:拡張可能な契約の定義

interface は、拡張可能な契約を定義する場合に使用します。特にオブジェクトの形状を定義し、将来的に拡張される可能性がある場合に適しています。

interface を使うべき場面

以下の表は、interface を使うべき場面をまとめたものです。

#使用場面理由
1オブジェクトの形状定義構造的な型付けに適している
2継承や拡張が必要な場合extends で容易に拡張できる
3宣言マージが必要な場合同名の interface を複数宣言できる
4サードパーティライブラリの拡張モジュール拡張が可能

基本的な interface の使い方

まず、基本的なオブジェクトの形状を定義します。

typescript// 記事を表すインターフェース
export interface Article {
  id: string; // 記事の一意識別子
  title: string; // 記事タイトル
  content: string; // 記事本文
  authorId: string; // 著者の ID
  createdAt: Date; // 作成日時
  updatedAt: Date; // 更新日時
}

この Article インターフェースは、記事オブジェクトが持つべきプロパティを定義しています。

次に、この Article を拡張して、公開済み記事を表すインターフェースを作成します。

typescript// 公開済み記事のインターフェース(Article を拡張)
export interface PublishedArticle extends Article {
  publishedAt: Date; // 公開日時
  slug: string; // URL 用のスラッグ
  tags: string[]; // タグの配列
}

extends キーワードを使うことで、既存のインターフェースを拡張できます。これにより、コードの重複を避けつつ、型の階層構造を表現できます。

宣言マージの活用

interface の強力な機能の 1 つが宣言マージです。同名のインターフェースを複数回宣言すると、それらは自動的にマージされます。

typescript// 基本的なプラグインのインターフェース
export interface Plugin {
  name: string; // プラグイン名
  version: string; // バージョン
}

後から、別のファイルや別の場所で同じインターフェースを拡張できます。

typescript// 同名のインターフェースを宣言(マージされる)
export interface Plugin {
  initialize: () => void; // 初期化関数
  destroy: () => void; // 破棄関数
}

この 2 つの宣言は自動的にマージされ、最終的に Plugin インターフェースは 4 つのプロパティを持つことになります。

サードパーティライブラリの拡張

interface の宣言マージは、サードパーティライブラリの型定義を拡張する際にも役立ちます。

typescript// Express の Request 型にカスタムプロパティを追加
declare global {
  namespace Express {
    interface Request {
      userId?: string; // 認証されたユーザーの ID
      sessionId?: string; // セッション ID
    }
  }
}

この宣言により、Express の Request 型にカスタムプロパティを追加できます。これにより、ミドルウェアで追加したプロパティに型安全にアクセスできます。

class の責務:データと振る舞いの提供

class は、データと振る舞いをセットで提供する場合に使用します。インスタンス化可能で、実装を含む点が typeinterface と大きく異なります。

class を使うべき場面

以下の表は、class を使うべき場面をまとめたものです。

#使用場面理由
1データと操作をカプセル化したい状態と振る舞いを 1 つにまとめられる
2インスタンスを生成して使いたいnew でインスタンス化できる
3継承による拡張が必要extends でクラスを拡張できる
4デコレーターを使いたいクラスはデコレーターに対応

基本的な class の使い方

まず、シンプルなクラスの定義から見てみましょう。

typescript// バリデーター基底クラス
export class Validator {
  protected errors: string[] = []; // エラーメッセージの配列

  // エラーを追加するメソッド
  protected addError(message: string): void {
    this.errors.push(message);
  }
}

この Validator クラスは、エラーメッセージを管理する基底クラスです。protected 修飾子により、派生クラスからアクセスできます。

次に、この基底クラスを拡張して、具体的なバリデーターを作成します。

typescript// メールアドレスバリデーター(Validator を拡張)
export class EmailValidator extends Validator {
  // メールアドレスの検証
  validate(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

    if (!emailRegex.test(email)) {
      this.addError('無効なメールアドレス形式です');
      return false;
    }

    return true;
  }

  // エラーメッセージの取得
  getErrors(): string[] {
    return [...this.errors]; // コピーを返して内部状態を保護
  }
}

この EmailValidator クラスは、メールアドレスの検証ロジックを実装しています。利用者は以下のように使用できます。

typescript// EmailValidator の使用例
const validator = new EmailValidator();

if (!validator.validate('invalid-email')) {
  console.log(validator.getErrors()); // ['無効なメールアドレス形式です']
}

型としても使える class

class は、インスタンスを生成できるだけでなく、型としても使用できます。

typescript// クラスを型として使用
function processValidator(validator: EmailValidator): void {
  // validator は EmailValidator のインスタンスでなければならない
  console.log(validator.getErrors());
}

この柔軟性により、class は実装と型定義の両方を提供できます。

classinterface の組み合わせ

classinterface を実装することで、契約を明示できます。

typescript// バリデーターのインターフェース
export interface IValidator {
  validate(value: string): boolean;
  getErrors(): string[];
}

このインターフェースを実装するクラスを作成します。

typescript// インターフェースを実装したクラス
export class PasswordValidator implements IValidator {
  private errors: string[] = [];
  private minLength: number;

  constructor(minLength: number = 8) {
    this.minLength = minLength;
  }

  validate(password: string): boolean {
    this.errors = []; // エラーをリセット

    if (password.length < this.minLength) {
      this.errors.push(
        `パスワードは${this.minLength}文字以上必要です`
      );
      return false;
    }

    return true;
  }

  getErrors(): string[] {
    return [...this.errors];
  }
}

implements キーワードを使うことで、IValidator インターフェースが要求するメソッドを実装する必要があります。これにより、契約の遵守が保証されます。

境界設計の実践:公開 API と内部実装の分離

公開 API を設計する際、何を export し、何を内部実装に留めるかは重要な判断です。

公開すべき型と公開すべきでない型

以下の表は、公開すべき型と公開すべきでない型の判断基準をまとめたものです。

#公開すべき型公開すべきでない型
1利用者が直接使用する型内部実装の詳細に関する型
2API の引数や戻り値の型中間処理で使用する一時的な型
3拡張やカスタマイズに必要な型変更の可能性が高い型
4ドキュメント化すべき型実装の最適化に関わる型

実践例:ストレージ API の設計

以下は、ストレージ API の設計例です。公開 API と内部実装を明確に分離しています。

まず、公開する型定義から始めます。

typescript// 公開する型定義(利用者が使用する)
export type StorageKey = string;

export interface StorageItem<T = unknown> {
  key: StorageKey;
  value: T;
  expiresAt?: number; // 有効期限(Unix タイムスタンプ)
}

これらの型は、利用者が直接使用するため export しています。

次に、公開する API のインターフェースを定義します。

typescript// ストレージ操作のインターフェース(公開 API)
export interface IStorage {
  get<T>(key: StorageKey): T | null;
  set<T>(key: StorageKey, value: T, ttl?: number): void;
  delete(key: StorageKey): void;
  clear(): void;
}

この IStorage インターフェースが、利用者との契約となります。

一方で、内部実装の詳細は非公開にします。

typescript// 内部で使用する型(非公開)
type InternalStorageItem = {
  v: unknown; // value の略(メモリ削減のため)
  e?: number; // expiresAt の略
  c: number; // createdAt(作成日時)
};

// 内部で使用するヘルパー関数(非公開)
function isExpired(item: InternalStorageItem): boolean {
  return item.e !== undefined && item.e < Date.now();
}

これらの型や関数は、export していないため、利用者からはアクセスできません。内部実装の変更の自由度を保つことができます。

最後に、公開 API を実装したクラスを作成します。

typescript// ストレージの実装(公開)
export class MemoryStorage implements IStorage {
  private store: Map<StorageKey, InternalStorageItem> =
    new Map();

  get<T>(key: StorageKey): T | null {
    const item = this.store.get(key);

    if (!item) {
      return null;
    }

    if (isExpired(item)) {
      this.store.delete(key);
      return null;
    }

    return item.v as T;
  }

  set<T>(key: StorageKey, value: T, ttl?: number): void {
    const item: InternalStorageItem = {
      v: value,
      c: Date.now(),
    };

    if (ttl !== undefined) {
      item.e = Date.now() + ttl;
    }

    this.store.set(key, item);
  }

  delete(key: StorageKey): void {
    this.store.delete(key);
  }

  clear(): void {
    this.store.clear();
  }
}

このクラスは IStorage インターフェースを実装しており、利用者は型安全に使用できます。内部実装の InternalStorageItemisExpired は非公開なので、将来的に変更しても利用者のコードに影響を与えません。

以下の図は、公開 API と内部実装の関係を示しています。

mermaidflowchart TB
  user["利用者のコード"]

  subgraph public["公開 API (export)"]
    storage_if["IStorage interface"]
    storage_class["MemoryStorage class"]
    storage_types["StorageKey type<br/>StorageItem interface"]
  end

  subgraph internal["内部実装 (非 export)"]
    internal_types["InternalStorageItem type"]
    helpers["isExpired 関数"]
  end

  user -->|使用| storage_if
  user -->|インスタンス化| storage_class
  user -->|型チェック| storage_types

  storage_class -.->|実装| storage_if
  storage_class -.->|依存| internal_types
  storage_class -.->|依存| helpers

  style public fill:#e1f5e1
  style internal fill:#f5e1e1

図で理解できる要点:

  • 公開 API は利用者が直接使用する
  • 内部実装は公開 API からのみアクセス
  • 境界を明確にすることで保守性が向上

具体例

例 1:設定オブジェクトの型設計

ライブラリの初期化時に渡す設定オブジェクトの型設計を考えてみましょう。

要件

  • 基本設定とオプション設定を分ける
  • 利用者が一部の設定だけを上書きできるようにする
  • 型安全に設定を扱える

設計

まず、必須の設定項目を定義します。

typescript// 必須設定項目
export interface RequiredConfig {
  apiKey: string; // API キー(必須)
  endpoint: string; // エンドポイント URL(必須)
}

次に、オプションの設定項目を定義します。

typescript// オプション設定項目
export interface OptionalConfig {
  timeout?: number; // タイムアウト(デフォルト: 30000)
  retryCount?: number; // リトライ回数(デフォルト: 3)
  debug?: boolean; // デバッグモード(デフォルト: false)
  headers?: Record<string, string>; // カスタムヘッダー
}

これら 2 つを組み合わせて、完全な設定型を作成します。

typescript// 完全な設定型(必須 + オプション)
export type Config = RequiredConfig & OptionalConfig;

交差型(&)を使うことで、2 つの型を結合できます。

デフォルト値を持つ設定オブジェクトを作成するヘルパー関数を定義します。

typescript// デフォルト設定
const DEFAULT_CONFIG: OptionalConfig = {
  timeout: 30000,
  retryCount: 3,
  debug: false,
};

// 設定をマージするヘルパー関数
export function createConfig(
  required: RequiredConfig,
  optional?: OptionalConfig
): Config {
  return {
    ...DEFAULT_CONFIG,
    ...required,
    ...optional,
  };
}

この関数により、利用者は必須項目だけを指定し、オプション項目はデフォルト値を使うか、一部だけを上書きできます。

typescript// 使用例
const config = createConfig(
  {
    apiKey: 'your-api-key',
    endpoint: 'https://api.example.com',
  },
  {
    debug: true, // debug だけを上書き
  }
);

この設計により、型安全性と柔軟性を両立できます。

例 2:プラグインシステムの型設計

拡張可能なプラグインシステムを設計する場合を考えてみましょう。

要件

  • プラグインのインターフェースを定義する
  • プラグインが提供すべきメソッドを明示する
  • 型安全にプラグインを登録・実行できる

設計

まず、プラグインのライフサイクルを表すインターフェースを定義します。

typescript// プラグインのインターフェース
export interface Plugin {
  name: string; // プラグイン名
  version: string; // バージョン
}

// プラグインのライフサイクルメソッド
export interface PluginLifecycle {
  onInit?: () => void | Promise<void>; // 初期化時
  onDestroy?: () => void | Promise<void>; // 破棄時
}

プラグインの基本情報とライフサイクルを分離することで、責務が明確になります。

次に、プラグインを管理するマネージャークラスを作成します。

typescript// プラグインの完全な型
export type FullPlugin = Plugin & PluginLifecycle;

// プラグインマネージャー
export class PluginManager {
  private plugins: Map<string, FullPlugin> = new Map();
}

プラグインを登録するメソッドを実装します。

typescript// プラグインマネージャーにメソッドを追加
export class PluginManager {
  private plugins: Map<string, FullPlugin> = new Map();

  // プラグインを登録
  async register(plugin: FullPlugin): Promise<void> {
    if (this.plugins.has(plugin.name)) {
      throw new Error(
        `プラグイン "${plugin.name}" は既に登録されています`
      );
    }

    this.plugins.set(plugin.name, plugin);

    // 初期化処理があれば実行
    if (plugin.onInit) {
      await plugin.onInit();
    }
  }
}

登録されたプラグインを取得するメソッドも追加します。

typescript// プラグインマネージャーにメソッドを追加
export class PluginManager {
  private plugins: Map<string, FullPlugin> = new Map();

  // プラグインを登録
  async register(plugin: FullPlugin): Promise<void> {
    if (this.plugins.has(plugin.name)) {
      throw new Error(
        `プラグイン "${plugin.name}" は既に登録されています`
      );
    }

    this.plugins.set(plugin.name, plugin);

    if (plugin.onInit) {
      await plugin.onInit();
    }
  }

  // プラグインを取得
  get(name: string): FullPlugin | undefined {
    return this.plugins.get(name);
  }

  // すべてのプラグインを破棄
  async destroyAll(): Promise<void> {
    for (const plugin of this.plugins.values()) {
      if (plugin.onDestroy) {
        await plugin.onDestroy();
      }
    }

    this.plugins.clear();
  }
}

この設計により、プラグインの登録・管理が型安全に行えます。

利用者は以下のようにプラグインを作成できます。

typescript// プラグインの実装例
const loggingPlugin: FullPlugin = {
  name: 'logging',
  version: '1.0.0',

  onInit: async () => {
    console.log('ロギングプラグインを初期化しました');
  },

  onDestroy: async () => {
    console.log('ロギングプラグインを破棄しました');
  },
};

// プラグインマネージャーの使用例
const manager = new PluginManager();
await manager.register(loggingPlugin);

以下の図は、プラグインシステムの全体像を示しています。

mermaidflowchart TB
  manager["PluginManager"]

  subgraph plugins["登録されたプラグイン"]
    p1["Plugin 1<br/>(name, version, lifecycle)"]
    p2["Plugin 2<br/>(name, version, lifecycle)"]
    p3["Plugin 3<br/>(name, version, lifecycle)"]
  end

  manager -->|register| p1
  manager -->|register| p2
  manager -->|register| p3

  manager -->|onInit 実行| p1
  manager -->|onInit 実行| p2
  manager -->|onInit 実行| p3

  manager -->|onDestroy 実行| p1
  manager -->|onDestroy 実行| p2
  manager -->|onDestroy 実行| p3

図で理解できる要点:

  • PluginManager が複数のプラグインを管理
  • 各プラグインはライフサイクルメソッドを持つ
  • 型安全にプラグインを登録・実行

例 3:イベントエミッターの型設計

型安全なイベントエミッターを設計する場合を考えてみましょう。

要件

  • イベント名とペイロードの型を関連付ける
  • 型安全にイベントを発行・購読できる
  • 未定義のイベントにはコンパイルエラーを出す

設計

まず、イベント名とペイロードの型を関連付けるマップを定義します。

typescript// イベント名とペイロードの型マップ
export interface EventMap {
  'user:created': { userId: string; email: string };
  'user:deleted': { userId: string };
  'article:published': { articleId: string; title: string };
}

この EventMap インターフェースにより、各イベント名に対応するペイロードの型が明確になります。

次に、イベントエミッターのクラスを作成します。

typescript// イベントエミッターの基底クラス
export class EventEmitter<
  TEventMap extends Record<string, any>
> {
  private listeners: Map<keyof TEventMap, Set<Function>> =
    new Map();
}

ジェネリクスを使うことで、イベントマップを型パラメータとして受け取れます。

イベントを購読するメソッドを実装します。

typescriptexport class EventEmitter<
  TEventMap extends Record<string, any>
> {
  private listeners: Map<keyof TEventMap, Set<Function>> =
    new Map();

  // イベントを購読
  on<K extends keyof TEventMap>(
    event: K,
    listener: (payload: TEventMap[K]) => void
  ): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }

    this.listeners.get(event)!.add(listener);
  }
}

K extends keyof TEventMap により、存在するイベント名のみを受け付けます。また、リスナー関数の引数型は、そのイベントに対応するペイロード型になります。

イベントを発行するメソッドも実装します。

typescriptexport class EventEmitter<
  TEventMap extends Record<string, any>
> {
  private listeners: Map<keyof TEventMap, Set<Function>> =
    new Map();

  on<K extends keyof TEventMap>(
    event: K,
    listener: (payload: TEventMap[K]) => void
  ): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }

    this.listeners.get(event)!.add(listener);
  }

  // イベントを発行
  emit<K extends keyof TEventMap>(
    event: K,
    payload: TEventMap[K]
  ): void {
    const listeners = this.listeners.get(event);

    if (!listeners) {
      return;
    }

    for (const listener of listeners) {
      listener(payload);
    }
  }
}

emit メソッドも同様に、イベント名とペイロードの型が一致することを保証します。

購読を解除するメソッドも追加します。

typescriptexport class EventEmitter<
  TEventMap extends Record<string, any>
> {
  private listeners: Map<keyof TEventMap, Set<Function>> =
    new Map();

  on<K extends keyof TEventMap>(
    event: K,
    listener: (payload: TEventMap[K]) => void
  ): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }

    this.listeners.get(event)!.add(listener);
  }

  emit<K extends keyof TEventMap>(
    event: K,
    payload: TEventMap[K]
  ): void {
    const listeners = this.listeners.get(event);

    if (!listeners) {
      return;
    }

    for (const listener of listeners) {
      listener(payload);
    }
  }

  // イベントの購読を解除
  off<K extends keyof TEventMap>(
    event: K,
    listener: (payload: TEventMap[K]) => void
  ): void {
    const listeners = this.listeners.get(event);

    if (listeners) {
      listeners.delete(listener);
    }
  }
}

この設計により、型安全なイベントエミッターが実現できます。

利用者は以下のように使用できます。

typescript// イベントエミッターのインスタンスを作成
const emitter = new EventEmitter<EventMap>();

// イベントを購読(型安全)
emitter.on('user:created', (payload) => {
  // payload の型は { userId: string; email: string } と推論される
  console.log(`ユーザーが作成されました: ${payload.email}`);
});

// イベントを発行(型安全)
emitter.emit('user:created', {
  userId: '123',
  email: 'user@example.com',
});

// 存在しないイベント名はコンパイルエラー
// emitter.emit('invalid:event', {}); // Error!

// ペイロードの型が一致しない場合もコンパイルエラー
// emitter.emit('user:created', { userId: '123' }); // Error! email が不足

以下の図は、イベントエミッターの動作を示しています。

mermaidsequenceDiagram
  participant User as 利用者のコード
  participant Emitter as EventEmitter
  participant Listener as リスナー関数

  User->>Emitter: on('user:created', listener)
  Emitter->>Emitter: リスナーを登録

  User->>Emitter: emit('user:created', payload)
  Emitter->>Listener: listener(payload)
  Listener-->>User: ログ出力など

  User->>Emitter: off('user:created', listener)
  Emitter->>Emitter: リスナーを削除

図で理解できる要点:

  • イベントの購読・発行・解除のフロー
  • ペイロードの型が保証される
  • 型安全にイベント駆動のコードが書ける

例 4:ビルダーパターンの型設計

複雑なオブジェクトを段階的に構築するビルダーパターンを、型安全に実装する例を見てみましょう。

要件

  • 必須パラメータの設定漏れを防ぐ
  • メソッドチェーンで流暢に記述できる
  • 最終的に型安全なオブジェクトを生成する

設計

まず、構築するオブジェクトの型を定義します。

typescript// HTTP リクエストの設定
export interface RequestConfig {
  url: string; // URL(必須)
  method: 'GET' | 'POST' | 'PUT' | 'DELETE'; // HTTP メソッド(必須)
  headers?: Record<string, string>; // ヘッダー
  body?: unknown; // リクエストボディ
  timeout?: number; // タイムアウト
}

次に、ビルダーの状態を型で表現します。必須パラメータが設定されているかを型レベルで追跡します。

typescript// ビルダーの状態を表す型
type BuilderState = {
  hasUrl: boolean;
  hasMethod: boolean;
};

// 初期状態(何も設定されていない)
type InitialState = {
  hasUrl: false;
  hasMethod: false;
};

ビルダークラスを作成します。ジェネリクスで状態を管理します。

typescript// ビルダークラス
export class RequestConfigBuilder<
  S extends BuilderState = InitialState
> {
  private config: Partial<RequestConfig> = {};

  // URL を設定
  url(
    url: string
  ): RequestConfigBuilder<S & { hasUrl: true }> {
    this.config.url = url;
    return this as any;
  }

  // HTTP メソッドを設定
  method(
    method: RequestConfig['method']
  ): RequestConfigBuilder<S & { hasMethod: true }> {
    this.config.method = method;
    return this as any;
  }
}

urlmethod を呼ぶと、状態が更新されます。

オプションのパラメータを設定するメソッドも追加します。

typescriptexport class RequestConfigBuilder<
  S extends BuilderState = InitialState
> {
  private config: Partial<RequestConfig> = {};

  url(
    url: string
  ): RequestConfigBuilder<S & { hasUrl: true }> {
    this.config.url = url;
    return this as any;
  }

  method(
    method: RequestConfig['method']
  ): RequestConfigBuilder<S & { hasMethod: true }> {
    this.config.method = method;
    return this as any;
  }

  // ヘッダーを設定
  headers(headers: Record<string, string>): this {
    this.config.headers = headers;
    return this;
  }

  // ボディを設定
  body(body: unknown): this {
    this.config.body = body;
    return this;
  }

  // タイムアウトを設定
  timeout(timeout: number): this {
    this.config.timeout = timeout;
    return this;
  }
}

最後に、build メソッドを追加します。このメソッドは、必須パラメータがすべて設定されている場合のみ呼べるようにします。

typescriptexport class RequestConfigBuilder<
  S extends BuilderState = InitialState
> {
  private config: Partial<RequestConfig> = {};

  url(
    url: string
  ): RequestConfigBuilder<S & { hasUrl: true }> {
    this.config.url = url;
    return this as any;
  }

  method(
    method: RequestConfig['method']
  ): RequestConfigBuilder<S & { hasMethod: true }> {
    this.config.method = method;
    return this as any;
  }

  headers(headers: Record<string, string>): this {
    this.config.headers = headers;
    return this;
  }

  body(body: unknown): this {
    this.config.body = body;
    return this;
  }

  timeout(timeout: number): this {
    this.config.timeout = timeout;
    return this;
  }

  // ビルド(必須パラメータがすべて設定されている場合のみ呼べる)
  build(
    this: RequestConfigBuilder<{
      hasUrl: true;
      hasMethod: true;
    }>
  ): RequestConfig {
    return this.config as RequestConfig;
  }
}

build メソッドの this パラメータにより、hasUrlhasMethodtrue でなければコンパイルエラーになります。

利用者は以下のように使用できます。

typescript// ビルダーの使用例(正常)
const config = new RequestConfigBuilder()
  .url('https://api.example.com/users')
  .method('POST')
  .headers({ 'Content-Type': 'application/json' })
  .body({ name: 'Alice' })
  .build(); // OK

// 必須パラメータが不足している場合はコンパイルエラー
const invalidConfig = new RequestConfigBuilder()
  .url('https://api.example.com/users')
  // .method('POST') を忘れた
  .build(); // Error! method が設定されていない

この設計により、コンパイル時に必須パラメータの設定漏れを検出できます。

以下の図は、ビルダーパターンの状態遷移を示しています。

mermaidstateDiagram-v2
  [*] --> Initial: new RequestConfigBuilder()

  Initial --> HasUrl: url()
  Initial --> HasMethod: method()

  HasUrl --> Complete: method()
  HasMethod --> Complete: url()

  Complete --> Complete: headers()<br/>body()<br/>timeout()

  Complete --> [*]: build()

  note right of Complete
    必須パラメータが
    すべて設定された状態
  end note

図で理解できる要点:

  • ビルダーの状態遷移を型で表現
  • 必須パラメータがすべて設定されるまで build を呼べない
  • 型安全にオブジェクトを構築

まとめ

TypeScript の公開 API における型設計は、利用者との契約を明確にし、型安全性と保守性を高めるために重要です。

本記事で解説した内容をまとめます。

export typeinterfaceclass の使い分け

#型定義方法責務使うべき場面
1export type純粋な型情報の提供ユニオン型、型演算、エイリアス
2interface拡張可能な契約の定義オブジェクトの形状、継承、宣言マージ
3classデータと振る舞いの提供インスタンス化、カプセル化、実装の提供

境界設計のポイント

  • 公開 API と内部実装を明確に分離する: export するものとしないものを意識的に選ぶ
  • 内部実装の詳細を隠蔽する: 変更の自由度を保ち、互換性を維持する
  • 利用者が必要な型だけを公開する: ドキュメント性を高め、誤用を防ぐ

型設計のベストプラクティス

  • 型の責務を明確にする: 各型が何を表現し、どのような契約を提供するかを明示する
  • 拡張性を考慮する: interface の継承や宣言マージを活用し、将来の拡張に備える
  • 型安全性を最大化する: ジェネリクスや条件型を活用し、コンパイル時にエラーを検出する
  • シンプルさを保つ: 複雑すぎる型は理解しにくくなるため、適度に分割する

型設計は、コードの品質を左右する重要な要素です。export typeinterfaceclass の特性を理解し、適切に使い分けることで、利用者にとって使いやすく、保守しやすい公開 API を実現できます。

これらの原則を意識しながら型設計を行うことで、長期的に保守可能なライブラリやパッケージを開発できるでしょう。

関連リンク