TypeScript DI(依存性の注入)の極意:Interfaceを境界線とした「破綻しない」型設計戦略
テックリードの〇〇だ。
コードレビューをしていて、最も頭痛が痛くなる瞬間が何か分かるか?
それは、ビジネスロジックが特定の外部ライブラリや具象クラス(Concrete Class)に強く結合しており、「テストのためにモックへ差し替えたい」「APIクライアントをFetchからAxios、あるいはgRPCへと移行したい」という要件が出た途端、アプリ全体がガラガラと崩壊する瞬間だ。
「とりあえず `any` や `unknown` で逃げる」「`as` キャストで型エラーをねじ伏せる」――そんなコードを書いていないか?
TypeScriptにおける `interface` は、単なるオブジェクトの形を定義するボイラープレートではない。モジュール間の境界線を定義し、コンパイルタイムの型安全性と実行時アーキテクチャを完全に分離するための「最強の抽象化レイヤー」だ。
今回は、フロントエンドからNode.jsのバックエンドまで、実務の現場で即座に応用できる、Interfaceを用いた堅牢な依存性の注入(DI)の型定義戦略を伝授しよう。
—
1. なぜ「具象への依存」は悪なのか?(コンパイラの視点)
多くの開発者は、次のようなコードを書く。
// ❌ アンチパターン:具象クラスに直接依存している
import { AxiosHttpClient } from ‘./AxiosHttpClient’;
export class UserService {
private client = new AxiosHttpClient();
async getUser(id: string) {
// Axios特有のレスポンス構造や例外処理がロジックに漏れ出している
const res = await this.client.get(`/users/${id}`);
return res.data;
}
}
この何が問題か?
1. テスタビリティの欠如: 単体テスト(Unit Test)時に本物のネットワークリクエストが発生するか、あるいはモック化のために複雑なJestのモジュールモックが必要になる。
2. 変更耐性のゼロ: `AxiosHttpClient` のインターフェースが変わった瞬間、`UserService` を修正しなければならない(単一責任の原則の違反)。
TypeScriptのコンパイラは、具象クラスをそのまま型として使うと、そのクラスが持つすべてのプロパティとメソッド(内部実装の細部まで)を型チェックの対象にしてしまう。我々が本当に必要なのは、「こういうメソッドを持っているはずだ」という振る舞いの契約(Contract)なのだ。
—
2. 解決策:Interfaceによる「契約(Contract)」の分離
依存性の注入の本質は、「上位のモジュールも下位のモジュールも、共に抽象に依存すべきである(DIP: 依存性逆転の原則)」という点にある。
TypeScriptにおいて、この「抽象」を最も美しく表現できるのが `interface` だ。
プロダクションコード例:堅牢なDIアーキテクチャ
以下のコードを見てほしい。ここでは、HTTPクライアント、ストレージ、そしてそれを利用するサービスクラスを完全にInterfaceで疎結合化している。
/
- ==========================================
- 1. 抽象(Interface)の定義
- ==========================================
/
// 外部通信の抽象化
export interface IHttpClient {
get
post
}
// ユーザーデータの型
export interface User {
id: string;
name: string;
email: string;
}
// ユーザーサービスの抽象化(利用側のインターフェース)
export interface IUserService {
getUserProfile(userId: string): Promise
}
/
- ==========================================
- 2. 具象(Implementation)の定義
- ==========================================
/
// 実装A: Fetch APIによるHTTPクライアントの具象
export class FetchHttpClient implements IHttpClient {
async get
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json() as Promise
}
async post
const response = await fetch(url, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’, …headers },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json() as Promise
}
}
// 実装B: ユーザーサービスの具象(IHttpClientに依存)
export class UserService implements IUserService {
// コンストラクタインジェクション:具象ではなく「Interface」を受け取る
constructor(private readonly httpClient: IHttpClient) {}
async getUserProfile(userId: string): Promise
// UserServiceは内部でFetchが使われているかAxiosが使われているかを知る必要がない
return this.httpClient.get
}
}
/
- ==========================================
- 3. テスト用のモック実装(Interfaceがあるからこそ容易)
- ==========================================
/
export class MockUserService implements IUserService {
async getUserProfile(userId: string): Promise
// テスト時はネットワークを叩かず、即座にモックデータを返す
return {
id: userId,
name: ‘Test User’,
email: ‘test@example.com’,
};
}
}
—
3. なぜ `interface` なのか? `type`(型エイリアス)ではダメなのか?
コードレビューでよくある質問だ。「`type IHttpClient = { … }` でも良くないですか?」と。
結論から言えば、オブジェクトの構造定義という意味では挙動は似ているが、拡張性とパフォーマンス、そして意図の明確さにおいて `interface` に軍配が上がる。
1. 宣言的マージ(Declaration Merging):
`interface` は同名のものを再宣言して拡張できる。これは、外部ライブラリの型拡張や、大規模プロジェクトでのモジュール拡張において強力な武器になる。
2. コンパイラのパフォーマンス:
TypeScriptの型チェッカーは、`interface` の方を効率的にキャッシュ・評価する。複雑なユニオン型を多用する `type` に比べ、オブジェクトの形を定義する `interface` はコンパイル負荷が低い。
3. アーキテクチャ上の意図(セマンティクス):
`type` は「型の別名(UnionやIntersection、プリミティブの制約など)」を作るために使い、`interface` は「オブジェクトの契約(Contract)」を作るために使う。この使い分けが、コードの可読性を劇的に引き上げる。
—
4. コンポジション・ルート(Composition Root)による配線
InterfaceとDIクラスを用意しても、それをどこかで「組み立てる(配線する)」必要がある。これをコンポジション・ルートと呼ぶ。
ReactのContextや、Node.jsのDIコンテナ(InversifyJSやTSyDIなど)を使う場合でも、基本の思想は同じだ。純粋なTypeScriptであれば、次のようにエントリーポイントでインスタンスを注入する。
// main.ts (アプリケーションの起爆点)
import { FetchHttpClient, UserService, IUserService } from ‘./services’;
// 1. 具象を選択してインスタンス化
const httpClient = new FetchHttpClient();
// 2. 依存関係を注入してサービスを生成
const userService: IUserService = new UserService(httpClient);
// 3. アプリケーション層へ渡す
async function bootstrap(service: IUserService, userId: string) {
const user = await service.getUserProfile(userId);
console.log(‘Fetched User:’, user);
}
bootstrap(userService, ‘12345’);
もし明日、「Fetchから、キャッシュ機能付きのGraphQLクライアントに置き換えたい」となっても、`IHttpClient` を満たす新しいクラスを作り、コンポジション・ルートで差し替えるだけだ。ビジネスロジック層(`UserService`)の一行たりとも書き換える必要はない。
—
テックリードからの総括
TypeScriptにおける `interface` を用いたDIは、単なる「お作法」ではない。それは、「変更に強く、テスト容易性が高く、チーム開発でコンフリクトやバグを生みにくいコードベース」を手に入れるための最強の型戦略だ。
「動けばいいや」で書かれたコードは、仕様変更の波が来た瞬間に技術的負債という名の津波となって開発者を飲み込む。
しかし、今日から君が書くコードは違う。Interfaceという堅牢な防壁に守られた、美しく拡張性のあるアーキテクチャになるはずだ。
次のコードレビューでは、具象クラスへの直接依存を見つけたら、こう指摘してやってほしい。
「おい、そこにInterfaceの境界線を引こうか」と。