TypeScriptでInversifyJSを使った依存性注入を行い型安全を維持する設計
TypeScript で InversifyJS を使った依存性注入を行い型安全を維持する設計
TypeScript プロジェクトに依存性注入(DI)を導入する際、型安全性が損なわれるケースは少なくありません。本記事では、InversifyJS を使った DI 導入時に型が壊れないようにするための設計と注意点を整理します。「DI を入れたらコンパイルエラーが消えたのに実行時にクラッシュした」という経験がある方、あるいはこれから DI を導入しようとしている方に向けて、実務で検証した知見をまとめました。
InversifyJS と他の DI 手法における型安全性の比較
| 手法 | 型安全性 | コンパイル時検査 | 実行時エラーリスク | 学習コスト |
|---|---|---|---|---|
| InversifyJS | 高い | Symbol + インターフェースで担保 | 低い(設定ミス時のみ) | 中程度 |
| 手動 DI | 中程度 | 引数の型で担保 | 中程度(注入漏れ) | 低い |
| TSyringe | 中程度 | トークンベース | 中程度 | 低い |
| Service Locator | 低い | any 型になりやすい | 高い | 低い |
InversifyJS は TypeScript のインターフェースと Symbol を組み合わせることで、コンパイル時の型検査と実行時の依存解決を両立できます。それぞれの詳細は後述します。
検証環境
- OS: macOS Sequoia 15.2
- Node.js: 22.13.0
- TypeScript: 5.7.3
- 主要パッケージ:
- inversify: 7.0.1
- reflect-metadata: 0.2.2
- 検証日: 2026 年 01 月 22 日
依存性注入と型安全性が両立しにくい背景
この章では、なぜ DI 導入時に型安全性が問題になるのかを説明します。
依存性注入(DI)とは、クラスが必要とする依存オブジェクトを外部から渡す設計パターンです。テストしやすさや保守性の向上が主な目的ですが、TypeScript で DI を実装する際には型情報の扱いに注意が必要です。
つまずきやすい点:JavaScript の実行時には TypeScript の型情報が消えるため、DI コンテナが型を認識できない。
型情報が消える問題
TypeScript の型はコンパイル時にのみ存在し、実行時には消去されます。以下のコードで具体的に見てみましょう。
typescript// TypeScriptのコード
interface ILogger {
log(message: string): void;
}
// コンパイル後のJavaScript(型情報が消えている)
// interface ILogger は完全に消去される
この「型消去」により、DI コンテナは「どのインターフェースにどの実装を紐づけるか」を型情報だけでは判断できません。
従来の手動 DI の限界
手動 DI では、コンストラクタの引数として依存を受け取ります。
typescriptclass UserService {
constructor(
private database: IDatabase,
private logger: ILogger,
) {}
}
// 利用側で依存を組み立てる
const service = new UserService(new MySQLDatabase(), new ConsoleLogger());
この方法は型安全ですが、依存関係が増えると組み立てコードが肥大化し、管理が困難になります。実際に試したところ、サービスが 20 を超えたあたりから依存の組み立てだけで 100 行以上になり、変更時の影響範囲も把握しづらくなりました。
DI 導入時に型安全性が壊れる典型的な課題
この章では、DI を導入した際に実際に発生しやすい型安全性の問題を取り上げます。
課題 1:any 型への逃げによる型情報の喪失
DI コンテナから取得したオブジェクトを any 型で受け取ってしまうパターンです。
typescript// 問題のあるコード:any型で受け取っている
const userService = container.get("UserService") as any;
userService.getUser("123"); // コンパイルは通るが型チェックが効かない
このコードはコンパイルエラーにならないため、メソッド名のタイポや引数の型ミスに気づけません。
課題 2:文字列キーによる識別子の衝突
文字列をキーとして使うと、異なるモジュールで同じ文字列を使ってしまうリスクがあります。
typescript// moduleA.ts
container.bind<ILogger>("Logger").to(FileLogger);
// moduleB.ts(別の開発者が書いたコード)
container.bind<ILogger>("Logger").to(ConsoleLogger);
// 上書きされてしまい、意図しない実装が注入される
業務で問題になったのは、サードパーティのライブラリが内部で同じ文字列キーを使っていたケースでした。原因特定に半日かかりました。
課題 3:インターフェースと実装の不一致
インターフェースを変更したのに、実装クラスの更新を忘れるケースです。
typescriptinterface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>; // 新規追加
}
@injectable()
class UserRepository implements IUserRepository {
async findById(id: string): Promise<User | null> {
// 実装あり
}
// findByEmail の実装を忘れている
// → コンパイルエラーになるが、DIコンテナの設定によっては
// 実行時まで気づかないことがある
}
以下の図は、型安全性が壊れる典型的なパターンを示しています。
mermaidflowchart TD
subgraph Problem["型安全性が壊れるパターン"]
A["any型への逃げ"] --> D["実行時エラー"]
B["文字列キーの衝突"] --> D
C["インターフェース<br/>不一致"] --> D
end
subgraph Solution["InversifyJSによる解決"]
E["Symbol識別子"] --> H["コンパイル時検査"]
F["@injectable<br/>デコレータ"] --> H
G["インターフェース<br/>バインディング"] --> H
end
この図は、左側が型安全性が壊れるパターン、右側が InversifyJS による解決策を表しています。
InversifyJS による型安全な設計と判断基準
この章では、InversifyJS を使って型安全性を維持する具体的な方法を説明します。
Symbol を使った型安全な識別子の設計
InversifyJS では、Symbol を識別子として使うことで、文字列キーの衝突問題を回避できます。
typescript// src/types.ts
export const TYPES = {
Database: Symbol.for("Database"),
Logger: Symbol.for("Logger"),
UserService: Symbol.for("UserService"),
UserRepository: Symbol.for("UserRepository"),
} as const;
Symbol.for() を使うことで、同じ文字列から常に同じ Symbol が得られます。これにより、モジュール間でも一貫した識別子を使用できます。
つまずきやすい点:
Symbol()とSymbol.for()は異なる。前者は毎回新しい Symbol を生成するため、DI ではSymbol.for()を使う。
@injectable デコレータによる依存関係の明示
InversifyJS の @injectable デコレータを使うと、クラスが DI コンテナで管理されることを明示できます。
typescriptimport { injectable, inject } from "inversify";
import { TYPES } from "./types";
@injectable()
export class UserService {
constructor(
@inject(TYPES.Database) private database: IDatabase,
@inject(TYPES.Logger) private logger: ILogger,
) {}
async getUser(id: string): Promise<User | null> {
this.logger.log(`ユーザー取得: ${id}`);
return this.database.findUser(id);
}
}
@inject デコレータにより、どの依存がどの識別子に対応するかがコード上で明確になります。
インターフェースとバインディングの型整合性
コンテナの設定時に、インターフェースと実装の型を明示的に指定します。
typescriptimport { Container } from "inversify";
const container = new Container();
// 型パラメータでインターフェースを指定
container.bind<IDatabase>(TYPES.Database).to(MySQLDatabase);
container.bind<ILogger>(TYPES.Logger).to(ConsoleLogger);
container.bind<UserService>(TYPES.UserService).to(UserService);
型パラメータ <IDatabase> を指定することで、MySQLDatabase が IDatabase を実装していなければコンパイルエラーになります。
採用しなかった設計案
検証の結果、以下の設計案は採用しませんでした。
クラスをそのまま識別子として使う方法
typescript// 採用しなかった案
container.bind(UserService).toSelf();
この方法はシンプルですが、インターフェースへの依存ではなく具象クラスへの依存になってしまい、テスト時のモック差し替えが困難になります。
文字列リテラル型を使う方法
typescript// 採用しなかった案
type ServiceKeys = "Database" | "Logger";
container.bind<IDatabase>("Database").to(MySQLDatabase);
型安全性は向上しますが、Symbol に比べて衝突リスクが残るため見送りました。
InversifyJS を使った型安全な実装の具体例
この章では、実際に動作するコードを示しながら、型安全な DI の実装方法を解説します。
プロジェクト構成
bashsrc/
├── types.ts # Symbol識別子の定義
├── interfaces/ # インターフェース定義
│ ├── IDatabase.ts
│ └── ILogger.ts
├── services/ # 実装クラス
│ ├── MySQLDatabase.ts
│ └── ConsoleLogger.ts
├── container.ts # DIコンテナ設定
└── main.ts # エントリーポイント
インターフェースの定義
まず、依存関係のインターフェースを定義します。
typescript// src/interfaces/IDatabase.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface IDatabase {
findUser(id: string): Promise<User | null>;
saveUser(user: Omit<User, "id">): Promise<User>;
}
typescript// src/interfaces/ILogger.ts
export interface ILogger {
log(message: string): void;
error(message: string, error?: Error): void;
}
実装クラスの作成
インターフェースを実装するクラスには @injectable デコレータを付けます。
typescript// src/services/MySQLDatabase.ts
import { injectable } from "inversify";
import { IDatabase, User } from "../interfaces/IDatabase";
@injectable()
export class MySQLDatabase implements IDatabase {
async findUser(id: string): Promise<User | null> {
// 実際のDB接続ロジック
console.log(`MySQL検索: ${id}`);
return {
id,
name: "テストユーザー",
email: "test@example.com",
};
}
async saveUser(user: Omit<User, "id">): Promise<User> {
const id = crypto.randomUUID();
console.log(`MySQL保存: ${user.name}`);
return { id, ...user };
}
}
typescript// src/services/ConsoleLogger.ts
import { injectable } from "inversify";
import { ILogger } from "../interfaces/ILogger";
@injectable()
export class ConsoleLogger implements ILogger {
log(message: string): void {
console.log(`[INFO] ${new Date().toISOString()}: ${message}`);
}
error(message: string, error?: Error): void {
console.error(`[ERROR] ${new Date().toISOString()}: ${message}`);
if (error?.stack) {
console.error(error.stack);
}
}
}
DI コンテナの設定
コンテナの設定を専用ファイルにまとめます。
typescript// src/container.ts
import "reflect-metadata";
import { Container } from "inversify";
import { TYPES } from "./types";
import { IDatabase } from "./interfaces/IDatabase";
import { ILogger } from "./interfaces/ILogger";
import { MySQLDatabase } from "./services/MySQLDatabase";
import { ConsoleLogger } from "./services/ConsoleLogger";
import { UserService } from "./services/UserService";
const container = new Container();
// インターフェースと実装のバインディング
container.bind<IDatabase>(TYPES.Database).to(MySQLDatabase);
container.bind<ILogger>(TYPES.Logger).to(ConsoleLogger);
container.bind<UserService>(TYPES.UserService).to(UserService);
export { container };
サービスクラスの実装
依存を注入されるサービスクラスです。
typescript// src/services/UserService.ts
import { injectable, inject } from "inversify";
import { TYPES } from "../types";
import { IDatabase, User } from "../interfaces/IDatabase";
import { ILogger } from "../interfaces/ILogger";
@injectable()
export class UserService {
constructor(
@inject(TYPES.Database) private database: IDatabase,
@inject(TYPES.Logger) private logger: ILogger,
) {}
async getUser(id: string): Promise<User | null> {
this.logger.log(`ユーザー取得開始: ${id}`);
const user = await this.database.findUser(id);
if (!user) {
this.logger.error(`ユーザーが見つかりません: ${id}`);
return null;
}
this.logger.log(`ユーザー取得完了: ${user.name}`);
return user;
}
}
エントリーポイント
アプリケーションの起動時に DI コンテナからサービスを取得します。
typescript// src/main.ts
import { container } from "./container";
import { TYPES } from "./types";
import { UserService } from "./services/UserService";
async function main() {
// 型安全にサービスを取得
const userService = container.get<UserService>(TYPES.UserService);
const user = await userService.getUser("user-001");
if (user) {
console.log(`取得したユーザー: ${user.name}`);
}
}
main().catch(console.error);
テスト時のモック差し替え
DI の利点が発揮されるテストコードの例です。
typescript// src/__tests__/UserService.test.ts
import "reflect-metadata";
import { Container } from "inversify";
import { TYPES } from "../types";
import { UserService } from "../services/UserService";
import { IDatabase, User } from "../interfaces/IDatabase";
import { ILogger } from "../interfaces/ILogger";
// モック実装
const mockDatabase: IDatabase = {
findUser: jest.fn().mockResolvedValue({
id: "test-id",
name: "テストユーザー",
email: "test@example.com",
}),
saveUser: jest.fn(),
};
const mockLogger: ILogger = {
log: jest.fn(),
error: jest.fn(),
};
describe("UserService", () => {
let container: Container;
let userService: UserService;
beforeEach(() => {
container = new Container();
// モックをバインド
container.bind<IDatabase>(TYPES.Database).toConstantValue(mockDatabase);
container.bind<ILogger>(TYPES.Logger).toConstantValue(mockLogger);
container.bind<UserService>(TYPES.UserService).to(UserService);
userService = container.get<UserService>(TYPES.UserService);
});
it("ユーザーを正常に取得できる", async () => {
const user = await userService.getUser("test-id");
expect(user).not.toBeNull();
expect(user?.name).toBe("テストユーザー");
expect(mockLogger.log).toHaveBeenCalled();
});
});
以下の図は、DI コンテナによる依存解決の流れを示しています。
mermaidsequenceDiagram
participant Main as main.ts
participant Container as DIコンテナ
participant Service as UserService
participant DB as IDatabase
participant Log as ILogger
Main->>Container: get<UserService>(TYPES.UserService)
Container->>Container: 依存関係を解決
Container->>DB: MySQLDatabaseを生成
Container->>Log: ConsoleLoggerを生成
Container->>Service: 依存を注入して生成
Container-->>Main: UserServiceインスタンス
Main->>Service: getUser("user-001")
Service->>Log: log("ユーザー取得開始")
Service->>DB: findUser("user-001")
DB-->>Service: User
Service-->>Main: User
この図は、DI コンテナがサービスの依存関係を自動的に解決し、必要なインスタンスを生成して注入する流れを示しています。
tsconfig.json の設定における注意点
InversifyJS を使うには、TypeScript の設定で以下のオプションを有効にする必要があります。
json{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
つまずきやすい点:
emitDecoratorMetadataをfalseにすると、@injectデコレータが正しく動作せず、実行時にundefinedが注入される。
検証の結果、strictNullChecks を有効にしておくことで、null 安全性と DI の型安全性を同時に担保できることがわかりました。
InversifyJS と他の DI 手法の詳細比較
この章では、判断基準を含めた詳細な比較を行います。
| 観点 | InversifyJS | 手動 DI | TSyringe | Service Locator |
|---|---|---|---|---|
| 型安全性 | ◎ Symbol + ジェネリクス | ○ 引数の型 | ○ トークン | × any になりやすい |
| コンパイル時検査 | ◎ バインディングで検査 | ◎ 引数で検査 | ○ 部分的 | × なし |
| テスト容易性 | ◎ モック差し替え簡単 | ○ 引数で渡す | ◎ 簡単 | △ グローバル状態 |
| スケーラビリティ | ◎ 大規模対応 | △ 組み立てが肥大化 | ○ 中規模向け | × 管理困難 |
| 学習コスト | △ デコレータの理解必要 | ◎ 低い | ○ 低め | ◎ 低い |
| デバッグ容易性 | ○ スタックトレース明確 | ◎ 追跡しやすい | ○ 普通 | × 追跡困難 |
向いているケース
InversifyJS が向いているケース
- サービスが 10 以上ある中〜大規模プロジェクト
- テストカバレッジを重視するプロジェクト
- チーム開発で依存関係を明示したい場合
手動 DI が向いているケース
- サービスが少ない小規模プロジェクト
- ライブラリの依存を増やしたくない場合
- DI コンテナの学習コストを避けたい場合
InversifyJS が向かないケース
- デコレータを使いたくない場合
- バンドルサイズを極限まで小さくしたい場合
- TypeScript を使わないプロジェクト
まとめ
InversifyJS を使った型安全な DI 設計のポイントを整理します。
- Symbol 識別子を使う:文字列キーの衝突を防ぎ、一意性を担保する
- インターフェースに依存する:具象クラスではなくインターフェースをバインディングの基準にする
- 型パラメータを明示する:
container.bind<IDatabase>()のように型を指定し、コンパイル時検査を有効にする - tsconfig.json を正しく設定する:
emitDecoratorMetadataとexperimentalDecoratorsを有効にする
DI は万能ではありません。小規模なプロジェクトでは手動 DI で十分なケースも多く、InversifyJS の導入は依存関係の複雑さに応じて判断すべきです。ただし、プロジェクトが成長する見込みがある場合、早い段階で InversifyJS を導入しておくと、後から設計を変更するコストを抑えられます。
型安全性を維持しながら DI を導入することで、テストしやすく保守性の高いコードベースを構築できます。本記事で紹介した設計パターンが、TypeScript プロジェクトの品質向上に役立てば幸いです。
関連リンク
著書
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
article2026年1月23日TypeScript 5.8の型推論を比較・検証する 強化点と落とし穴の回避策
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月22日TypeScriptの型システムを概要で理解する 基礎から全体像まで完全解説
article2026年1月22日ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践
article2026年1月22日TypeScriptでよく出るエラーをトラブルシュートでまとめる 原因と解決法30選
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
