こんにちは!フロントエンドからNode.jsまで、TypeScriptの荒波を乗りこなす先輩エンジニアのあなたへ。
今回は、TypeScriptの型システムが持つ「本質的な美しさ」に触れられる、非常におもしろいテーマを用意しました。それが「クラスのコンストラクタを型として捉え、自前でミニマムなDI(依存性の注入)コンテナを実装する」というアプローチです。
他のオブジェクト指向言語(JavaやC#など)からやってきた方なら、「DIってなに?」「TypeScriptでどう型安全に書くの?」と気になるところですよね。
ここをクリアできれば、TypeScriptの型システムと実行時コードの境界線がクリアになり、ワンランク上のアーキテクトに近づけますよ。さあ、一緒に紐解いていきましょう!
—
1. そもそも「クラスのコンストラクタ」って型にできるの?
TypeScriptで日常的に書くクラス。例えば、次のようなコードがあったとします。
class UserService {
getName(): string {
return “Alice”;
}
}
ここで「`UserService` というインスタンス」ではなく、「`UserService` というクラスそのもの(設計図)」を別の関数に引数として渡したいとき、どう型注釈すればよいでしょうか?
初学者の頃は「`UserService`でいいのかな?」と思いがちですが、それだと「生成されたインスタンスの型」になってしまいます。設計図そのものを指す型、それが コンストラクタ型(Constructor Type) です。
コンストラクタ型の基本文法: `new (…args: any[]) => T`
TypeScriptでは、`new` 演算子をつけて呼び出せるシグネチャを次のように表現します。
// T というインスタンスを生成するコンストラクタの型
type Constructor
- `new (…)`: 「`new` してインスタンス化できるよ」という意味。
- `…args: any[]`: コンストラクタにはどんな引数(パラメータ)があるかわからないので、可変長引数を受け取れるようにしています。
- `=> T`: 最終的に型 `T` のインスタンスを返すよ、ということです。
これを使えば、「どんなクラスの設計図でも受け取れるファクトリ関数」の型が綺麗に定義できますよね。
—
2. 実践:簡易DIコンテナを実装してみよう
DI(Dependency Injection:依存性の注入)の本質は、「オブジェクトが自分で依存先を作るのではなく、外から外部のクラスやモジュールを注入してもらう」 という設計パターンです。
今回は、クラスのコンストラクタを登録し、依存関係を自動で解決してインスタンスを生成してくれる、「ミニマムDIコンテナ」 をTypeScriptで実装してみましょう。
脳内イメージとしては、こんな感じです。
[ DIコンテナ ]
┣ 登録 (register): クラスの設計図を蓄える
┗ 生成 (resolve): 「このクラスをくれ!」と言われたら、
必要な依存関係を自動でnewして組み立てて渡す
実際のコードを見てください。
// 1. 簡易DIコンテナのクラスを定義します
class Container {
// 登録されたコンストラクタを保持するマップ
private registry = new Map
/
- クラスをコンテナに登録するメソッド
- key: 識別子(例: “UserService”)
- target: クラスのコンストラクタ
/
public register
this.registry.set(key, target);
}
/
- 登録されたキーからインスタンスを生成して返すメソッド
- ジェネリクスを使って、呼び出し側でキャストしなくても型安全に取得できるようにします
/
public resolve
const TargetConstructor = this.registry.get(key);
if (!TargetConstructor) {
throw new Error(`[DI Error] 登録されていないキーです: ${key}`);
}
// ここで動的にインスタンスを生成!
return new TargetConstructor();
}
}
どう動くのか?使ってみよう
では、この `Container` を実際に使ってみましょう。データベースにアクセスするサービスと、それを呼び出すコントローラーを想定します。
// データベースサービス
class Database {
connect() {
console.log(“データベースに接続しました!”);
}
}
// ユーザーサービス(Databaseに依存しているとする)
class UserService {
constructor() {}
login() {
console.log(“ユーザーがログインしました。”);
}
}
// — 実行のシュミレーション —
const container = new Container();
// コンテナにクラスの「設計図(コンストラクタ)」を登録する
container.register(“Database”, Database);
container.register(“UserService”, UserService);
// コンテナからインスタンスを解決(取得)する
const db = container.resolve
db.connect(); // 実行結果: データベースに接続しました!
const userService = container.resolve
userService.login(); // 実行結果: ユーザーがログインしました。
おぉ、見事に型安全かつ動的にインスタンスが生成されていますね!
—
3. 陥りやすい文法エラーと「型アサーション」の罠
ここで、もう少し踏み込んだ実践的な課題に直面します。
「もし、クラスのコンストラクタに引数(依存する別のクラス)があったらどうなるでしょう?」
例えば、`UserService` が内部で `Database` を必要とする場合です。
class UserService {
// コンストラクタに Database を要求する
constructor(private db: Database) {}
}
先ほどの単純な `new TargetConstructor()` だと、引数が渡されていないため、コンパイルエラー(または実行時エラー)になってしまいますよね。
本格的なDIコンテナ(NestJSやInversifyJSなど)は、TypeScriptの実験的機能である `emitDecoratorMetadata` や、Reflect APIを使って、引数の型情報をコンパイル後も保持し、自動的に依存オブジェクトを再帰的に解決(オートワイヤリング)しています。
もし自前で簡易的に書く場合は、コンストラクタの引数を型として厳密に縛る必要があります。
よくある間違い:`Function` 型で雑に受け取ってしまう
// ❌ 絶対にやってはいけないアンチパターン
class BadContainer {
private registry = new Map
register(key: string, target: Function) {
this.registry.set(key, target);
}
resolve(key: string) {
const Target = this.registry.get(key);
// 型が `Function` なので、new できるかどうかがコンパイラに保証されない!
return new (Target as any)();
}
}
`Function` 型や `any` に逃げてしまうと、TypeScriptを使っている意味がほとんどなくなってしまいます。
必ず、「`new` 可能であり、かつ特定の型 `T` を返すこと」 を保証するために、先ほど紹介した `new (…args: any[]) => T` というシグネチャを使いましょう。
—
まとめ:ここをクリアすればTypeScriptの型マスター!
今回は、以下の重要なポイントを学びました。
1. クラスの設計図(コンストラクタ)は `new (…args: any[]) => T` という型で表現できる。
2. DIコンテナの登録・解決メカニズムにおいて、このコンストラクタ型を活用することで、実行時とコンパイル時の型安全性を両立できる。
3. `any` や `Function` に逃げず、ジェネリクス (`
DIの仕組みは一見難しく感じますが、「クラスをただの値(オブジェクト)として扱い、後から `new` する」という本質が分かってしまえば、TypeScriptの型システムの強力な味方になります。
ここをマスターできれば、大規模なフロントエンド設計や、NestJSのようなモダンなバックエンドフレームワークのソースコードもすんなり読めるようになりますよ。
ぜひ、あなたのプロジェクトでも小さなコンテナを作って遊んでみてくださいね。それでは、次のステップへ進みましょう!