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

TypeScript でライブラリやパッケージを開発する際、公開 API の型設計は非常に重要です。利用者にとって使いやすく、保守しやすいコードを提供するには、export type
、interface
、class
をどのように使い分けるべきか悩むことも多いでしょう。
本記事では、TypeScript の公開 API における型設計の基本から実践的なテクニックまでを解説します。それぞれの型定義方法の責務を明確にし、境界設計の考え方を学ぶことで、拡張性と保守性を兼ね備えた API を設計できるようになるでしょう。
背景
TypeScript における型定義の選択肢
TypeScript で外部に公開する API を設計する際、主に以下の 3 つの方法で型を定義できます。
# | 定義方法 | 主な用途 | 特徴 |
---|---|---|---|
1 | type | 型エイリアス、ユニオン型、複雑な型の定義 | 柔軟性が高く、型演算が可能 |
2 | interface | オブジェクトの形状定義、拡張可能な契約 | 宣言マージが可能、継承しやすい |
3 | class | データと振る舞いを持つ実体 | インスタンス化可能、実装を含む |
それぞれの選択肢には明確な設計思想があり、適切に使い分けることで型安全性と可読性を両立できます。
以下の図は、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 のサポート強化: 自動補完やリファクタリングが容易になる
- ドキュメント性の向上: 型定義自体がドキュメントとして機能する
- 保守性の向上: 将来の変更や拡張がしやすくなる
一方で、型設計が不適切だと、利用者に余計な型アサーションを強いたり、型定義の意図が伝わりにくくなったりします。
課題
type
、interface
、class
の使い分けの難しさ
多くの開発者が直面する課題は、「いつ type
を使い、いつ interface
を使い、いつ class
を使うべきか」という判断です。
以下のような状況で迷うことが多いでしょう。
# | 状況 | 迷いやすいポイント |
---|---|---|
1 | オブジェクトの形状を定義したい | type と interface のどちらを選ぶべきか |
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
は、データと振る舞いをセットで提供する場合に使用します。インスタンス化可能で、実装を含む点が type
や interface
と大きく異なります。
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
は実装と型定義の両方を提供できます。
class
と interface
の組み合わせ
class
は interface
を実装することで、契約を明示できます。
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 | 利用者が直接使用する型 | 内部実装の詳細に関する型 |
2 | API の引数や戻り値の型 | 中間処理で使用する一時的な型 |
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
インターフェースを実装しており、利用者は型安全に使用できます。内部実装の InternalStorageItem
や isExpired
は非公開なので、将来的に変更しても利用者のコードに影響を与えません。
以下の図は、公開 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;
}
}
url
と method
を呼ぶと、状態が更新されます。
オプションのパラメータを設定するメソッドも追加します。
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
パラメータにより、hasUrl
と hasMethod
が true
でなければコンパイルエラーになります。
利用者は以下のように使用できます。
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 type
、interface
、class
の使い分け
# | 型定義方法 | 責務 | 使うべき場面 |
---|---|---|---|
1 | export type | 純粋な型情報の提供 | ユニオン型、型演算、エイリアス |
2 | interface | 拡張可能な契約の定義 | オブジェクトの形状、継承、宣言マージ |
3 | class | データと振る舞いの提供 | インスタンス化、カプセル化、実装の提供 |
境界設計のポイント
- 公開 API と内部実装を明確に分離する:
export
するものとしないものを意識的に選ぶ - 内部実装の詳細を隠蔽する: 変更の自由度を保ち、互換性を維持する
- 利用者が必要な型だけを公開する: ドキュメント性を高め、誤用を防ぐ
型設計のベストプラクティス
- 型の責務を明確にする: 各型が何を表現し、どのような契約を提供するかを明示する
- 拡張性を考慮する:
interface
の継承や宣言マージを活用し、将来の拡張に備える - 型安全性を最大化する: ジェネリクスや条件型を活用し、コンパイル時にエラーを検出する
- シンプルさを保つ: 複雑すぎる型は理解しにくくなるため、適度に分割する
型設計は、コードの品質を左右する重要な要素です。export type
、interface
、class
の特性を理解し、適切に使い分けることで、利用者にとって使いやすく、保守しやすい公開 API を実現できます。
これらの原則を意識しながら型設計を行うことで、長期的に保守可能なライブラリやパッケージを開発できるでしょう。
関連リンク
- article
TypeScript 公開 API の型設計術:`export type`/`interface`/`class`の責務分担と境界設計
- article
ESLint を Yarn + TypeScript + React でゼロから構築:Flat Config 完全手順(macOS)
- article
TypeScript 型縮小(narrowing)パターン早見表:`in`/`instanceof`/`is`/`asserts`完全対応
- article
TypeScript 共有可能な tsconfig 設計:`tsconfig/bases`で複数パッケージを一括最適化
- article
TypeScript ランタイム検証ライブラリ比較:Zod / Valibot / typia / io-ts の選び方
- article
【解決策】TypeScript TS2307「Cannot find module…」が出る本当の原因と最短復旧手順
- article
Vitest `vi` API 技術チートシート:`mock` / `fn` / `spyOn` / `advanceTimersByTime` 一覧
- article
Pinia ストア分割テンプレ集:domain/ui/session の三層パターン
- article
Obsidian Markdown 拡張チートシート:Callout/埋め込み/内部リンク完全網羅
- article
Micro Frontends 設計:`vite-plugin-federation` で分割可能な UI を構築
- article
TypeScript 公開 API の型設計術:`export type`/`interface`/`class`の責務分担と境界設計
- article
Nuxt nuxi コマンド速見表:プロジェクト作成からモジュール公開まで
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来