T-CREATOR

<div />

TypeScriptの公開APIを壊さない設計 export type interface classの責務分担を整理する

2025年12月25日
TypeScriptの公開APIを壊さない設計 export type interface classの責務分担を整理する

私が社内ライブラリの公開 API を設計していた際、typeinterfaceclassの使い分けで大きな失敗をした経験があります。v1.0 で公開した API の型定義が不適切だったため、v2.0 へのアップデート時に破壊的変更を余儀なくされ、利用チームから多くのクレームを受けました。

この記事は、TypeScript でライブラリやパッケージを開発し、公開 API の型設計で悩んでいるエンジニアの判断に役立つ内容です。具体的には、静的型付け言語である TypeScript において、export typeinterfaceclassの責務をどう分担すれば、後方互換性を保ちながら拡張可能な API を設計できるかを、実際の失敗経験と解決策を交えて解説します。

検証環境

本記事では、以下の環境で動作確認を行っています。

  • OS: macOS Sequoia 15.2
  • Node.js: 22.12.0 (LTS)
  • 主要パッケージ:
    • TypeScript: 5.7.2
    • ts-node: 10.9.2
  • 検証日: 2025 年 12 月 25 日

背景

TypeScript における静的型付けの選択肢とその迷い

TypeScript で外部に公開する API を設計する際、主に 3 つの方法で型を定義できます。しかし、この選択肢の多さが逆に設計の迷いを生むのです。

#定義方法主な用途実務での選択基準
1type型エイリアス、ユニオン型、複雑な型の定義型演算が必要な場合、将来変更しない前提
2interfaceオブジェクトの形状定義、拡張可能な契約利用者が拡張する可能性がある場合
3classデータと振る舞いを持つ実体インスタンス化が必要、状態管理が必要な場合

私が初めて社内ライブラリを設計した際、この 3 つの違いを深く理解せずに、「なんとなくtypeが便利そう」という理由だけで、すべてをtypeで定義してしまいました。その結果、後から拡張が必要になった際に、型定義の変更が破壊的変更となり、大きなトラブルを引き起こしたのです。

以下の図は、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 における型設計が実務に与える影響

ライブラリやパッケージの公開 API は、利用者との「契約」です。実務では、この契約の品質が開発効率に直結します。

私が経験した具体例として、認証ライブラリの API を設計した際、ユーザー情報を表す型を以下のように定義していました。

typescript// 初期バージョン(失敗例)
export type User = {
  id: string;
  name: string;
};

その後、メールアドレスのプロパティを追加する必要が生じましたが、既存の利用者のコードを壊さないようにオプショナルにする必要がありました。

typescript// v2.0で追加(破壊的変更を避けるため)
export type User = {
  id: string;
  name: string;
  email?: string; // 後から追加
};

しかし、実際には新規ユーザーにはemailが必須だったため、利用者側で型アサーションを多用するコードが増え、型安全性が失われてしまいました。もし最初からinterfaceで定義し、継承を活用していれば、この問題は避けられたはずです。

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

  • コンパイル時のエラー検出: ランタイムエラーを未然に防げる
  • IDE の自動補完強化: 開発効率が劇的に向上する
  • ドキュメント性の向上: 型定義自体が仕様書として機能する
  • 保守性の向上: 変更や拡張がしやすくなり、長期運用が可能になる

一方で、型設計が不適切だと、以下の問題が発生します。

  • 利用者に余計な型アサーションを強いる
  • 型定義の意図が伝わらず、誤用が増える
  • 破壊的変更を避けられず、メジャーバージョンアップが頻発する

課題

typeinterfaceclassの使い分けで実際に発生した問題

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

私が実際に遭遇した失敗例をいくつか紹介しましょう。

#失敗した状況選択ミスの内容影響
1設定オブジェクトをclassで定義単純なデータ構造にclassを使い、複雑化利用者がnew演算子を使う必要が生じた
2API レスポンス型をinterfaceで定義ユニオン型が必要なのにinterfaceを使った型の判別が困難になった
3プラグイン型をtypeで定義拡張可能にすべきなのにtypeで固定してしまった利用者が独自プロパティを追加できなかった
4バリデーター型をtypeで定義振る舞いが必要なのに型だけを定義した実装を別途用意する必要が生じた

特に問題となったのは、プラグイン型をtypeで定義してしまったケースです。

typescript// 失敗例:typeで定義
export type Plugin = {
  name: string;
  version: string;
};

この定義では、利用者が独自のプロパティを追加しようとしても、型エラーになってしまいます。

typescript// 利用者のコード(エラーになる)
const myPlugin: Plugin = {
  name: "my-plugin",
  version: "1.0.0",
  customProperty: "value", // Error: プロパティ 'customProperty' は型 'Plugin' に存在しません
};

この問題に気づいたのは、実際に社内の別チームから「プラグインに独自のメタデータを追加したいのですが、型エラーになります」という報告を受けてからでした。

公開 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 の意図が不明確になり、誤用が増える

私が経験した具体例として、ストレージライブラリの内部で使用していたキャッシュキーの生成関数を誤ってexportしてしまったことがあります。すると、一部の利用者がその関数に依存したコードを書いてしまい、後からキャッシュキーの形式を変更する際に破壊的変更となってしまいました。

型の拡張性と互換性のバランスで悩んだ実例

公開 API を設計する際、「拡張性」と「互換性」のバランスも重要な課題です。この 2 つは時にトレードオフの関係にあります。

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

実際に私が設計した HTTP クライアントライブラリでは、以下のような型定義をしていました。

typescript// 拡張性を重視した複雑な型定義
export type RequestOptions<T extends string = string> = {
  method: T;
  headers: Record<string, string | string[]>;
  body?: unknown;
  timeout?: number;
  retry?: {
    count: number;
    delay: number;
    backoff?: "linear" | "exponential";
  };
};

この型定義は柔軟性が高い一方で、利用者からは「複雑すぎて使いにくい」というフィードバックを多数受けました。結局、シンプルな基本型と拡張型に分割することで、この問題を解決しました。

解決策

export typeの責務:純粋な型情報の提供と適用場面

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

実務での使い分けとして、以下の表を参考にしてください。

#使用場面実務での判断理由
1ユニオン型やリテラル型の定義複数の選択肢を型レベルで表現する必要がある
2関数の引数や戻り値の型振る舞いを持たない純粋なデータ構造
3複雑な型の合成既存の型から新しい型を生成する必要がある
4型のエイリアス長い型名を短縮し、可読性を向上させる

ユニオン型でステータスを型安全に管理

まず、シンプルなユニオン型の定義から見てみましょう。これは静的型付けの基本的な活用例です。

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

このStatus型を使うことで、以下のような型安全なコードが書けます。

typescript// 型安全なステータス判定
function handleStatus(status: Status): string {
  switch (status) {
    case "pending":
      return "承認待ち";
    case "approved":
      return "承認済み";
    case "rejected":
      return "却下";
    // default が不要(すべてのケースを網羅)
  }
}

Status型に定義されていない文字列を渡すと、コンパイルエラーになるため、実行前に誤りを検出できます。

設定オプションを型エイリアスで定義

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

typescript// 設定オプションの型定義
export type Options = {
  timeout?: number;
  retryCount?: number;
  debug?: boolean;
};

すべてのプロパティをオプショナル(?)にすることで、利用者は必要なオプションのみを指定できます。

typescript// 使用例:必要なオプションのみ指定
const options: Options = {
  debug: true, // timeout と retryCount は省略
};

型演算で柔軟な型を生成

typeを使うと、既存の型から新しい型を生成する型演算が可能です。

まず、完全なユーザー情報の型を定義します。

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

このUser型から、パスワードを除いた公開用の型を生成します。

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

Omitユーティリティ型により、特定のプロパティを除外した新しい型を作れます。

さらに、条件分岐を含む高度な型定義も可能です。

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

この型を使うと、以下のように型を絞り込めます。

typescript// 型の絞り込み(Type Guard)
function processResponse<T>(response: ApiResponse<T>): void {
  if (response.success) {
    // この分岐では response.data にアクセス可能
    console.log(response.data);
  } else {
    // この分岐では response.error にアクセス可能
    console.log(response.error);
  }
}

✓ 動作確認済み(TypeScript 5.7.x / Node.js 22.x)

interfaceの責務:拡張可能な契約の定義と宣言マージ

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

実務での使い分けとして、以下の表を参考にしてください。

#使用場面実務での判断理由
1オブジェクトの形状定義構造的な型付けを活用したい
2継承や拡張が必要な場合基底型から派生型を作る設計が明確
3宣言マージが必要な場合プラグインなどで型を後から拡張したい
4サードパーティライブラリの拡張既存の型定義に独自のプロパティを追加したい

インターフェースで基本的なオブジェクト形状を定義

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

typescript// 記事を表すインターフェース
export interface Article {
  id: string;
  title: string;
  content: string;
  authorId: string;
  createdAt: Date;
  updatedAt: Date;
}

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

継承を活用した型の拡張

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

typescript// 公開済み記事のインターフェース(継承)
export interface PublishedArticle extends Article {
  publishedAt: Date;
  slug: string;
  tags: string[];
}

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

typescript// 使用例
const article: PublishedArticle = {
  // Article のプロパティ
  id: "123",
  title: "TypeScript の型設計",
  content: "...",
  authorId: "author-1",
  createdAt: new Date(),
  updatedAt: new Date(),
  // PublishedArticle 固有のプロパティ
  publishedAt: new Date(),
  slug: "typescript-type-design",
  tags: ["TypeScript", "設計"],
};

宣言マージで段階的に型を拡張

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

基本的なプラグインのインターフェースを定義します。

typescript// プラグインの基本情報
export interface Plugin {
  name: string;
  version: string;
}

後から、別のファイルや別の場所で同じインターフェースにプロパティを追加できます。

typescript// 同名のインターフェースを宣言(自動的にマージ)
export interface Plugin {
  initialize: () => void;
  destroy: () => void;
}

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

typescript// マージされた結果を使用
const plugin: Plugin = {
  name: "my-plugin",
  version: "1.0.0",
  initialize: () => console.log("初期化"),
  destroy: () => console.log("破棄"),
};

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

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

typescript// Express の Request 型にカスタムプロパティを追加
declare global {
  namespace Express {
    interface Request {
      userId?: string;
      sessionId?: string;
    }
  }
}

この宣言により、Express のRequest型にカスタムプロパティを追加できます。

typescript// ミドルウェアでカスタムプロパティを設定
app.use((req, res, next) => {
  req.userId = "user-123"; // 型安全にアクセス可能
  next();
});

✓ 動作確認済み(TypeScript 5.7.x / @types/express 4.17.x)

classの責務:データと振る舞いのカプセル化

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

実務での使い分けとして、以下の表を参考にしてください。

#使用場面実務での判断理由
1データと操作をカプセル化したい状態管理と操作を 1 つのユニットにまとめたい
2インスタンスを生成して使いたいオブジェクト指向設計を適用したい
3継承による拡張が必要共通処理を基底クラスにまとめたい
4デコレーターを使いたいメタデータや AOP 的な処理が必要

バリデーター基底クラスの設計

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

typescript// バリデーター基底クラス
export class Validator {
  protected errors: string[] = [];

  protected addError(message: string): void {
    this.errors.push(message);
  }
}

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

クラスの継承で具体的な機能を実装

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

typescript// メールアドレスバリデーター
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は、インスタンスを生成できるだけでなく、型としても使用できます。

typescript// クラスを型として使用
function processValidator(validator: EmailValidator): void {
  console.log(validator.getErrors());
}

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

インターフェースを実装したクラス

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インターフェースが要求するメソッドを実装する必要があります。これにより、契約の遵守が保証されます。

typescript// 使用例
const validator = new PasswordValidator(10);

if (!validator.validate("short")) {
  console.log(validator.getErrors());
  // ['パスワードは10文字以上必要です']
}

✓ 動作確認済み(TypeScript 5.7.x / Node.js 22.x)

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

公開 API を設計する際、何をexportし、何を内部実装に留めるかは重要な判断です。私が実務で学んだ判断基準を共有します。

公開すべき型と非公開にすべき型の判断基準

以下の表は、実務での判断基準をまとめたものです。

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

ストレージ API の境界設計実例

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

まず、公開する型定義を定義します。

typescript// 公開する型定義
export type StorageKey = string;

ストレージアイテムのインターフェースも公開します。

typescriptexport interface StorageItem<T = unknown> {
  key: StorageKey;
  value: T;
  expiresAt?: number;
}

次に、公開 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;
  e?: number;
  c: number;
};

内部で使用するヘルパー関数も非公開にします。

typescript// 内部ヘルパー関数(非公開)
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メソッドを実装します。

````typescript
  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 メソッドを実装します。

typescript  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 と clear メソッドを実装します。

typescript  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 からのみアクセスされます。この境界を明確にすることで、保守性が大幅に向上します。

✓ 動作確認済み(TypeScript 5.7.x / Node.js 22.x)

よくあるエラーと対処法

実際に TypeScript で公開 API を設計する際、いくつかのエラーに遭遇しました。ここでは、その経験を共有します。

エラー 1: Type 'X' is not assignable to type 'Y'

typeinterfaceを混在させた際に、以下のエラーが発生しました。

bashType '{ name: string; version: string; initialize: () => void; }' is not assignable to type 'Plugin'.
  Property 'initialize' does not exist on type 'Plugin'.

発生条件

  • typeで定義した型に、後から追加したプロパティを使おうとした場合
  • 宣言マージが使えないtypeを使用していた

原因

typeは宣言マージができないため、同名の型を複数回定義することができません。

解決方法

  1. typeinterfaceに変更する
  2. または、交差型(&)を使って新しい型を作成する
typescript// 修正前(エラーが発生)
export type Plugin = {
  name: string;
  version: string;
};

// 同名の type を再定義(エラー)
// export type Plugin = {
//   initialize: () => void;
// };

// 修正後(正常動作)
export interface Plugin {
  name: string;
  version: string;
}

// 宣言マージが可能
export interface Plugin {
  initialize: () => void;
}

解決後の確認

修正後、tsc --noEmitでビルドエラーが解消され、正常に動作することを確認しました。

参考リンク

エラー 2: Property 'X' is protected and only accessible within class 'Y' and its subclasses

クラスの継承を使った際に、以下のエラーが発生しました。

bashProperty 'errors' is protected and only accessible within class 'Validator' and its subclasses.

発生条件

  • protectedで定義したプロパティに、クラス外部からアクセスしようとした場合

原因

protected修飾子は、クラス内部と派生クラスからのみアクセス可能です。

解決方法

  1. 外部からアクセスする必要がある場合は、publicに変更する
  2. または、getterメソッドを用意する
typescript// 修正前(エラーが発生)
export class Validator {
  protected errors: string[] = [];
}

const validator = new Validator();
// console.log(validator.errors); // Error!

// 修正後(正常動作)
export class Validator {
  protected errors: string[] = [];

  // getter メソッドを追加
  getErrors(): string[] {
    return [...this.errors];
  }
}

const validator2 = new Validator();
console.log(validator2.getErrors()); // OK

解決後の確認

修正後、外部から安全にエラーメッセージにアクセスできることを確認しました。

参考リンク

具体例

例 1:設定オブジェクトの型設計で学んだこと

ライブラリの初期化時に渡す設定オブジェクトの型設計を考えてみましょう。これは実際に私が社内ライブラリで実装した例です。

要件

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

設計の判断理由

必須項目とオプション項目を明確に分離することで、利用者が最低限必要な設定を忘れることを防ぎます。また、TypeScript の型システムを活用し、コンパイル時にエラーを検出できるようにします。

必須設定項目の定義

まず、必須の設定項目をインターフェースで定義します。

typescript// 必須設定項目
export interface RequiredConfig {
  apiKey: string;
  endpoint: string;
}

オプション設定項目の定義

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

typescript// オプション設定項目
export interface OptionalConfig {
  timeout?: number;
  retryCount?: number;
  debug?: boolean;
  headers?: Record<string, string>;
}

完全な設定型の作成

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

typescript// 完全な設定型
export type Config = RequiredConfig & OptionalConfig;

交差型(&)を使うことで、2 つのインターフェースを結合できます。

デフォルト値とヘルパー関数

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

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

設定をマージするヘルパー関数を作成します。

typescript// 設定をマージ
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,
  }
);

この設計により、型安全性と柔軟性を両立できました。実際の運用では、この設計により設定ミスによるバグがゼロになりました。

✓ 動作確認済み(TypeScript 5.7.x / Node.js 22.x)

例 2:プラグインシステムの型設計で失敗から学んだこと

拡張可能なプラグインシステムを設計する際、当初はtypeを使っていましたが、拡張性の問題に直面しました。その経験を共有します。

要件

  • プラグインのインターフェースを定義する
  • プラグインが提供すべきメソッドを明示する
  • 型安全にプラグインを登録・実行できる
  • 利用者が独自プロパティを追加できるようにする

失敗した設計と改善

当初は以下のようにtypeで定義していました。

typescript// 失敗例:拡張できない
export type Plugin = {
  name: string;
  version: string;
};

しかし、利用者が独自のプロパティを追加できないという問題が発生しました。そこで、interfaceに変更することで解決しました。

プラグインの基本情報を定義

まず、プラグインの基本情報をインターフェースで定義します。

typescript// プラグインの基本情報
export interface Plugin {
  name: string;
  version: string;
}

ライフサイクルメソッドを分離

プラグインのライフサイクルメソッドを別のインターフェースで定義します。

typescript// プラグインのライフサイクル
export interface PluginLifecycle {
  onInit?: () => void | Promise<void>;
  onDestroy?: () => void | Promise<void>;
}

責務を分離することで、それぞれの役割が明確になります。

完全なプラグイン型を作成

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

プラグインマネージャーの実装

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

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

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

````typescript
  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  get(name: string): FullPlugin | undefined {
    return this.plugins.get(name);
  }

すべてのプラグインを破棄するメソッドを実装します。

typescript  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("ロギングプラグインを破棄しました");
  },
};

プラグインマネージャーの使用例です。

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

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

mermaidflowchart TB
  manager["PluginManager"]

  subgraph plugins["登録されたプラグイン"]
    p1["Plugin 1<br/>(name, version,<br/>lifecycle)"]
    p2["Plugin 2<br/>(name, version,<br/>lifecycle)"]
    p3["Plugin 3<br/>(name, version,<br/>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 が複数のプラグインを一元管理し、各プラグインのライフサイクルメソッドを適切に実行していることが分かります。

この設計により、型安全にプラグインを管理でき、利用者が独自のプロパティを追加することも可能になりました。実際の運用では、社内の複数チームがこのプラグインシステムを使って独自の機能を追加しています。

✓ 動作確認済み(TypeScript 5.7.x / Node.js 22.x)

例 3:イベントエミッターの型設計で実現した完全な型安全性

型安全なイベントエミッターを設計する際、TypeScript の高度な型機能を活用することで、完全な型安全性を実現しました。

要件

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

イベントマップの定義

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

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();
}

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

イベント購読メソッドの実装

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

typescript  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により、存在するイベント名のみを受け付けます。また、リスナー関数の引数型は、そのイベントに対応するペイロード型になります。

イベント発行メソッドの実装

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

typescript  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メソッドも同様に、イベント名とペイロードの型が一致することを保証します。

購読解除メソッドの実装

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

typescript  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 の型は自動的に推論される
  console.log(`ユーザーが作成されました: ${payload.email}`);
});

イベントを発行する際も型安全です。

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

存在しないイベント名はコンパイルエラーになります。

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

ペイロードの型が一致しない場合もコンパイルエラーになります。

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

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

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: リスナーを削除

このシーケンス図から、イベントの購読・発行・解除のフローが明確になります。また、ペイロードの型が保証されるため、型安全にイベント駆動のコードが書けます。

この設計により、実行時エラーを大幅に削減できました。実際の運用では、イベント名の誤りやペイロードの型ミスによるバグがゼロになりました。

✓ 動作確認済み(TypeScript 5.7.x / Node.js 22.x)

例 4:ビルダーパターンで実現した必須パラメータの型レベル保証

複雑なオブジェクトを段階的に構築するビルダーパターンを、TypeScript の型システムを活用して型安全に実装した例を紹介します。

要件

  • 必須パラメータの設定漏れをコンパイル時に検出
  • メソッドチェーンで流暢に記述できる
  • 最終的に型安全なオブジェクトを生成する
  • オプションパラメータは柔軟に設定できる

構築するオブジェクトの型定義

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

typescript// HTTP リクエストの設定
export interface RequestConfig {
  url: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  body?: unknown;
  timeout?: number;
}

ビルダーの状態を型で表現

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

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

初期状態を定義します。

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

ビルダークラスの実装

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

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

URLを設定するメソッドを実装します。

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

HTTP メソッドを設定するメソッドを実装します。

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

オプションパラメータの設定メソッド

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

typescript  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メソッドを追加します。このメソッドは、必須パラメータがすべて設定されている場合のみ呼べるようにします。

typescript  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

必須パラメータが不足している場合はコンパイルエラーになります。

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

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

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を呼べないことが分かります。

この設計により、コンパイル時に必須パラメータの設定漏れを検出でき、実行時エラーを完全に防げました。実際の運用では、API リクエストの設定ミスによるバグがゼロになりました。

✓ 動作確認済み(TypeScript 5.7.x / Node.js 22.x)

まとめ

TypeScript の公開 API における型設計は、利用者との契約を明確にし、型安全性と保守性を高めるために不可欠です。ただし、すべての状況で完璧な設計は存在せず、プロジェクトの特性や要件に応じて適切な選択をする必要があります。

export typeinterfaceclassの責務分担

#型定義方法責務向いているケース
1export type純粋な型情報の提供ユニオン型、型演算、複雑な型の合成
2interface拡張可能な契約の定義オブジェクトの形状、継承、宣言マージ
3classデータと振る舞いの提供インスタンス化、カプセル化、状態管理

境界設計で学んだ実務の判断基準

  • 公開 API と内部実装を明確に分離する: 変更の自由度を保ち、互換性を維持できます
  • 内部実装の詳細を隠蔽する: 利用者が内部実装に依存しないようにします
  • 利用者が必要な型だけを公開する: ドキュメント性を高め、誤用を防ぎます
  • 変更の可能性が高い型は非公開にする: 将来の変更に柔軟に対応できます

型設計で失敗から学んだベストプラクティス

  • 型の責務を明確にする: 各型が何を表現し、どのような契約を提供するかを明示しましょう
  • 拡張性を考慮する: ただし、過度に複雑にならないようバランスを取ります
  • 型安全性を最大化する: ジェネリクスや条件型を活用し、コンパイル時にエラーを検出します
  • シンプルさを保つ: 複雑すぎる型は理解しにくくなるため、適度に分割しましょう
  • 実際の利用者のフィードバックを得る: 設計段階でレビューを受けることが重要です

向いているケースと向かないケース

この設計アプローチが向いているケース:

  • ライブラリやパッケージを開発し、長期的に保守する場合
  • 複数のチームや開発者が利用する API を設計する場合
  • 型安全性を重視し、コンパイル時にエラーを検出したい場合
  • 将来的な拡張や変更を見据えた設計が必要な場合

この設計アプローチが向かないケース:

  • プロトタイプや短期的なプロジェクトで、柔軟性を優先する場合
  • チーム全体が TypeScript の型システムに不慣れな場合(学習コストが高い)
  • シンプルなデータ構造のみで、複雑な型演算が不要な場合

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

ただし、最も重要なのは、実際に手を動かして試行錯誤することです。私自身、多くの失敗を経験することで、これらの判断基準を身につけることができました。この記事が、皆さんの型設計の参考になれば幸いです。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;