T-CREATOR

<div />

TypeScriptでInversifyJSを使った依存性注入を行い型安全を維持する設計

2025年12月20日
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> を指定することで、MySQLDatabaseIDatabase を実装していなければコンパイルエラーになります。

採用しなかった設計案

検証の結果、以下の設計案は採用しませんでした。

クラスをそのまま識別子として使う方法

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
  }
}

つまずきやすい点emitDecoratorMetadatafalse にすると、@inject デコレータが正しく動作せず、実行時に undefined が注入される。

検証の結果、strictNullChecks を有効にしておくことで、null 安全性と DI の型安全性を同時に担保できることがわかりました。

InversifyJS と他の DI 手法の詳細比較

この章では、判断基準を含めた詳細な比較を行います。

観点InversifyJS手動 DITSyringeService Locator
型安全性◎ Symbol + ジェネリクス○ 引数の型○ トークン× any になりやすい
コンパイル時検査◎ バインディングで検査◎ 引数で検査○ 部分的× なし
テスト容易性◎ モック差し替え簡単○ 引数で渡す◎ 簡単△ グローバル状態
スケーラビリティ◎ 大規模対応△ 組み立てが肥大化○ 中規模向け× 管理困難
学習コスト△ デコレータの理解必要◎ 低い○ 低め◎ 低い
デバッグ容易性○ スタックトレース明確◎ 追跡しやすい○ 普通× 追跡困難

向いているケース

InversifyJS が向いているケース

  • サービスが 10 以上ある中〜大規模プロジェクト
  • テストカバレッジを重視するプロジェクト
  • チーム開発で依存関係を明示したい場合

手動 DI が向いているケース

  • サービスが少ない小規模プロジェクト
  • ライブラリの依存を増やしたくない場合
  • DI コンテナの学習コストを避けたい場合

InversifyJS が向かないケース

  • デコレータを使いたくない場合
  • バンドルサイズを極限まで小さくしたい場合
  • TypeScript を使わないプロジェクト

まとめ

InversifyJS を使った型安全な DI 設計のポイントを整理します。

  1. Symbol 識別子を使う:文字列キーの衝突を防ぎ、一意性を担保する
  2. インターフェースに依存する:具象クラスではなくインターフェースをバインディングの基準にする
  3. 型パラメータを明示するcontainer.bind<IDatabase>() のように型を指定し、コンパイル時検査を有効にする
  4. tsconfig.json を正しく設定するemitDecoratorMetadataexperimentalDecorators を有効にする

DI は万能ではありません。小規模なプロジェクトでは手動 DI で十分なケースも多く、InversifyJS の導入は依存関係の複雑さに応じて判断すべきです。ただし、プロジェクトが成長する見込みがある場合、早い段階で InversifyJS を導入しておくと、後から設計を変更するコストを抑えられます。

型安全性を維持しながら DI を導入することで、テストしやすく保守性の高いコードベースを構築できます。本記事で紹介した設計パターンが、TypeScript プロジェクトの品質向上に役立てば幸いです。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;