【TypeScript】DIコンテナの心臓部:コンストラクタ型とジェネリクスを極限まで型安全にする設計術
コードレビューをしていて、よくこんな実装に出くわさないだろうか?
// 良くあるアンチパターン
class UserService {}
function resolve(target: any): any {
return new target();
}
const service = resolve(UserService); // 型は ‘any’
実務において、DI(依存性注入)コンテナやプラグイン機構、あるいはコンポーネントの動的生成を行う際、「クラスのコンストラクタを引数に受け取り、そのインスタンスを型安全に返す」という要件は頻出する。しかし、これを適当な `any` や抽象的な `Function` 型で逃げているようでは、TypeScriptを採用している意味の半分は失われていると言っていい。
今回は、コンパイラが裏側でどう型を評価しているのかというメカニズムを踏まえつつ、実行時とコンパイル時の型を完全一致させ、バグの入り込む隙をゼロにしたプロダクション品質のDIコンテナ構築術を伝授する。
—
1. コンストラクタ型(`new (…args: any[]) => T`)の正体
TypeScriptにおいて、クラスとは「インスタンスの型」であると同時に「コンストラクター関数(値)」でもある。
クラス名(例:`UserService`)を型として指定した場合、それはインスタンスの型を指す。では、クラスそのもの(=インスタンスを生成する関数)を型として表現するにはどうすればよいか。
ここで登場するのが、シグネチャに `new` を冠したコンストラクターシグネチャだ。
// T というインスタンスを生成するコンストラクタの型定義
type Constructor
この型がコンパイラに何を伝えているか。それは、「`new` 演算子をつけて呼び出すことが可能であり、最終的に `T` 型のインスタンスを返す呼び出しブルな実体である」ということだ。
—
2. 実践:型推論を崩さないDIコンテナの実装
単にインスタンスを返すだけなら簡単だが、実務のDIコンテナは「どのような引数(依存関係)を持つクラスであっても、それを自動解決、あるいは型安全にインスタンス化できること」が求められる。
以下のコードを見てほしい。これは、フロントエンドのサービス層やNode.jsのバックエンドでそのまま使える、極限まで洗練された簡易DIコンテナのプロダクションコードだ。
/
- 任意のコンストラクタ型を表現する汎用ユーティリティ
/
type AbstractConstructor
type ConcreteConstructor
type TargetConstructor
/
- 堅牢なDIコンテナクラス
/
class Container {
// シングルトンやインスタンスのキャッシュを保持するマップ
private registry = new Map
/
- クラスを登録する(ファクトリ関数または直接のコンストラクタ)
/
public register
token: TargetConstructor
implementation?: TargetConstructor
): void {
const target = implementation ?? token;
// すでにインスタンスが渡されている場合はそのままキャッシュへ
if (typeof target === ‘object’ && target !== null) {
this.registry.set(token, target);
return;
}
// クラス(コンストラクタ)の場合はインスタンス化してキャッシュ
// ※本来のDIではここでコンストラクタの引数(依存性)を再帰的に解決する
const instance = new (target as ConcreteConstructor
this.registry.set(token, instance);
}
/
- 型安全にインスタンスを解決(Resolve)する
- @template T
- @param token – 解決したいクラスのコンストラクタ
- @returns Tのインスタンス
/
public resolve
const instance = this.registry.get(token);
if (!instance) {
throw new Error(`[DI Container] 未登録の依存関係です: ${token.name}`);
}
// 実行時の実体とコンパイル時の型(T)を完全に保証
return instance as T;
}
}
// ==========================================
// 使用例:実務におけるドメインモデルの構築
// ==========================================
class LoggerService {
public log(message: string) {
console.log(`[LOG]: ${message}`);
}
}
class ApiClient {
constructor() {}
public fetch() { return “Data”; }
}
class UserService {
// 依存関係を持つサービスクラス
constructor(
private logger: LoggerService,
private api: ApiClient
) {}
public getUserProfile() {
this.logger.log(“Fetching user profile…”);
return this.api.fetch();
}
}
// — 実行フェーズ —
const container = new Container();
container.register(LoggerService);
container.register(ApiClient);
// 型安全性の検証
// resolveの引数に渡したクラスから、戻り値の型が自動的に ‘UserService’ に推論される
// もしコンストラクタ以外の変数を渡せば、TypeScriptコンパイラが即座にエラーを吐く。
const logger = container.resolve(LoggerService); // 型は LoggerService
logger.log(“System initialized.”);
—
3. なぜこの設計が優れているのか?(テクニカルリードの視点)
このコードが優れている理由は、単に動くからではない。「型安全性の穴」を完全に塞ぎつつ、開発者の認知負荷を下げている点にある。
① `TargetConstructor` による抽象クラス(Abstract Class)の許容
実務のアーキテクチャ設計では、インターフェースの代わりに `abstract class` を依存性のトークンとして使うことが多い。
通常の `new (…args: any[]) => T具象型` だけだと、抽象クラス(`abstract class`)を渡したときにTypeScriptのコンパイラが型エラーを起こす。
`abstract new (…) => T` を型定義に含めることで、「実装を持たない抽象クラスをトークンにし、具象クラスをバインドする」というクリーンアーキテクチャ的な設計パターンに完全対応できる。
② `any` の汚染を防ぐスコープの限定
引数の `…args: any[]` は一見すると危険に見えるかもしれない。しかし、これは「どんな引数を持つコンストラクタであっても受け入れる」ためのライブラリ側の許容範囲(Contravariance)であり、外部に露出する戻り値の型は必ずジェネリクス `T` によって厳密に担保されている。
呼び出し側(Consumer)のコードで `as` キャストを書く必要は一切ない。
—
4. パフォーマンスとコンパイル時の注意点
大規模なフロントエンドアプリケーション(Next.jsやViteを用いたSPAなど)でDIコンテナやメタデータ駆動のフレームワークを使う際、以下の点に注意しなければならない。
1. Circular Dependency(循環依存)の検知
AがBを求め、BがAを求めるような循環依存がある場合、素朴なインスタンス化ロジックでは無限ループ(Maximum call stack size exceeded)に陥る。実務では、インスタンス化のライフサイクルを遅延評価(Lazy Evaluation)にするか、プロキシ(`Proxy`)を用いた遅延解決の仕組みを挟むべきである。
2. バンドルサイズとTree Shaking
クラスのコンストラクタをトークンとして大量に登録する場合、すべてのクラスがコードベースから参照され続けるため、バンデラーのTree Shaking(未使用コードの削除)が効きにくくなるケースがある。フロントエンドにおいては、文字列のトークン(SymbolやString Literal)を併用する設計も視野に入れること。
—
5. まとめ
TypeScriptにおけるクラスとコンストラクタの型定義をマスターすることは、単なる「型パズル」のスキルアップではない。
それは、「実行時エラーの温床となりやすい動的な仕組み」を、コンパイラの強固な型安全性の中に完全に封じ込めるという、アーキテクトとしての必須スキルである。
「なんとなく `any` や `Function` で繋いでいる」というコードがあれば、今すぐ今日紹介した `new (…args: any[]) => T` をベースにした型定義へリファクタリングしてほしい。IDEの補完が劇的に変わり、チーム全体の開発体験が跳ね上がることを約束しよう。