小泉伊澄:TypeScript 的分阶段依赖注入
Chibi Izumi: Phased dependency injection for TypeScript

原始链接: https://github.com/7mind/izumi-chibi-ts

## izumi-chibi-ts:TypeScript 中的 Scala Distage DI izumi-chibi-ts 是 Scala Izumi 项目核心概念在 TypeScript 中的重新实现,特别是 Distage 阶段依赖注入库。它旨在将 Distage 的强大功能——流畅的 DSL、类型安全的绑定和强大的错误检测——带到 TypeScript 项目中。 主要特性包括用于定义依赖项的 `ModuleDef`,用于自动依赖解析的 `@Reflected` 装饰器,以及对各种绑定类型(常规、集合、弱集合、别名、工厂)的支持。它还提供轴标记以进行条件绑定(例如开发/生产环境)以及具有并行执行的异步工厂支持。 该库将*规划*(分析依赖项并创建执行计划)与*生产*(实例化对象)分离,从而能够尽早检测到循环依赖或缺少绑定等错误。`Locator` 提供对创建的实例的访问。 虽然尚未经过充分测试,但 izumi-chibi-ts 通过类型安全、编译时验证和自动化功能,相对于手动依赖注入提供了显著的改进。它通过 npm (`@izumi-framework/izumi-chibi-ts`) 提供,并且需要在你的 `tsconfig.json` 中启用 `experimentalDecorators`。还有一个名为 `izumi-chibi-py` 的姊妹项目,用于 Python。

## Chibi Izumi:TypeScript 的分阶段依赖注入 - Hacker News 摘要 一个新的 TypeScript 库 Chibi Izumi 是基于 Scala 的 distage 的移植,提供了一种“分阶段”的依赖注入 (DI) 方法。作者 pshirshov 强调了它的优点,包括具有健全验证的可配置应用程序,以及与传统 DI 框架相比更易于理解/移植。 讨论引发了关于 DI 库在 TypeScript 中价值的争论,一些开发者更喜欢简单的构造函数参数。pshirshov 认为分阶段 DI 解决了生命周期问题和基本注入之外的复杂性,而另一些人则认为现有示例过于复杂。 Chibi Izumi 的关键特性包括使用“Functoids”(运行时可检查的函数/构造函数)和基于 DAG 的依赖关系解析方法,并在可能的情况下进行编译时验证。该库旨在“非侵入式”,并且*不需要*装饰器,提供替代的元数据选项。它主要是在 Claude LLM 的协助下进行原型设计的。
相关文章

原文

CI npm version codecov License: MIT

A TypeScript re-implementation of some core concepts from Scala's Izumi Project, distage staged dependency injection library in particular.

The port was done by guiding Claude with partial manual reviews.

At this point the project is not battle-tested. Expect dragons, landmines and varying mileage.

Sibling project: izumi-chibi-py.

Other DI implementations for TypeScript/JavaScript

Library Non-invasive Staged DI Config Axes Async Lifecycle Factory Type Safety Set Bindings
izumi-chibi-ts
InversifyJS ⚠️ ⚠️
TSyringe ⚠️ ⚠️ ⚠️
TypeDI ⚠️ ⚠️ ⚠️ ⚠️
NestJS DI ⚠️ ⚠️
Awilix ⚠️
typed-inject ⚠️ ⚠️
BottleJS ⚠️

Legend: ✅ = Full support | ⚠️ = Partial/limited | ❌ = Not supported

distage brings the power of distage's staged dependency injection to TypeScript:

  • Fluent DSL for defining dependency injection modules
  • Type-safe bindings using TypeScript's type system
  • @Reflected decorator for automatic dependency resolution without duplication
  • Type-safe factory functions with parameter type inference
  • Multiple binding types: regular, set, weak set, aliases, factory bindings
  • Axis tagging for conditional bindings (e.g., dev vs prod implementations)
  • Named dependencies using @Id decorator
  • Async support with parallel execution for independent async factories
  • Functoid abstraction for representing dependency constructors
  • Fail-fast validation with circular and missing dependency detection
  • Planner/Producer separation for build-time analysis and runtime instantiation
  • Lifecycle management for resource acquisition and cleanup
npm install @izumi-framework/izumi-chibi-ts

Make sure to enable the following in your tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}
import { Injector, ModuleDef, Reflected, Id } from '@izumi-framework/izumi-chibi-ts';

// Define your classes with @Reflected decorator
class Config {
  constructor(public readonly env: string) {}
}

@Reflected(Config)
class Database {
  constructor(public readonly config: Config) {}
}

@Reflected(Database, String)
class UserService {
  constructor(
    public readonly db: Database,
    @Id('app-name') public readonly appName: string
  ) {}
}

// Define module with bindings
const module = new ModuleDef()
  .make(Config).from().value(new Config('production'))
  .make(Database).from().type(Database)  // @Reflected handles dependencies
  .make(String).named('app-name').from().value('MyApp')
  .make(UserService).from().type(UserService);  // @Reflected handles dependencies

// Create injector and produce instances
const injector = new Injector();
const userService = injector.produceByType(module, UserService);

console.log(userService.appName); // 'MyApp'
console.log(userService.db.config.env); // 'production'

@Reflected Decorator - Automatic Dependency Resolution

The @Reflected decorator stores constructor parameter types directly on the class, enabling automatic dependency resolution:

import { Reflected, Id } from '@izumi-framework/izumi-chibi-ts';

@Reflected(Database, Config)
class UserService {
  constructor(
    public readonly db: Database,
    public readonly config: Config
  ) {}
}

// TypeScript validates at compile-time that:
// - The number of types matches the constructor parameter count
// - The types are in the correct order
// - The types match the constructor parameter types

const module = new ModuleDef()
  .make(UserService).from().type(UserService);  // Dependencies auto-detected!

For third-party classes you can't modify, use ApplyReflection:

import { ApplyReflection } from '@izumi-framework/izumi-chibi-ts';

// Third-party class you can't modify
class ThirdPartyService {
  constructor(db: Database, config: Config) {}
}

// Add reflection metadata
ApplyReflection(ThirdPartyService, Database, Config);

// Now it works without .withDeps()
const module = new ModuleDef()
  .make(ThirdPartyService).from().type(ThirdPartyService);

ModuleDef - DSL for Defining Bindings

ModuleDef provides a fluent API for declaring how to create instances:

import { ModuleDef, Functoid } from '@izumi-framework/izumi-chibi-ts';

@Reflected(Config)
class Logger {
  constructor(public readonly config: Config) {}
}

const module = new ModuleDef()
  // Bind to a value
  .make(Config).from().value(new Config('production'))

  // Bind to a class (with @Reflected)
  .make(Database).from().type(PostgresDatabase)

  // Bind using type-safe factory with .func()
  .make(Logger).from().func(
    [Config],
    (config) => new Logger(config)  // Types inferred automatically!
  )

  // Bind using a pre-built Functoid
  .make(Logger).from().functoid(
    Functoid.fromFunction([Config], (config) => new Logger(config))
  )

  // Create an alias
  .make(IDatabase).from().alias(PostgresDatabase);

Type-Safe Factory Functions

The .func() method and Functoid.fromFunction() provide type-safe factories with automatic type inference:

// Types are specified once, then inferred for parameters
const module = new ModuleDef()
  .make(UserService).from().func(
    [Database, Config],
    (db, config) => new UserService(db, config)
    // TypeScript infers: db: Database, config: Config
  );

// Benefits:
// - No type duplication
// - Compile-time validation of parameter count and order
// - Full type safety without 'as' casts

Use the @Id decorator to distinguish multiple bindings of the same type:

import { Id } from '@izumi-framework/izumi-chibi-ts';

@Reflected(Database, Database)
class Service {
  constructor(
    @Id('primary') public readonly primaryDb: Database,
    @Id('replica') public readonly replicaDb: Database
  ) {}
}

const module = new ModuleDef()
  .make(Database).named('primary').from().value(primaryDb)
  .make(Database).named('replica').from().value(replicaDb)
  .make(Service).from().type(Service);  // @Reflected + @Id work together

distage fully supports asynchronous factories with intelligent parallel execution:

@Reflected(DatabaseConfig)
class Database {
  constructor(public readonly config: DatabaseConfig) {}
  connected = false;

  async connect() {
    this.connected = true;
  }
}

const module = new ModuleDef()
  // Async factory
  .make(DatabaseConfig).from().func(
    [],
    async () => {
      // Simulate loading config from file
      const config = await loadConfigFromFile();
      return config;
    }
  )
  // Another async factory
  .make(Database).from().func(
    [DatabaseConfig],
    async (config) => {
      const db = new Database(config);
      await db.connect();
      return db;
    }
  );

// Use produceAsync for async graphs
const injector = new Injector();
const locator = await injector.produceAsync(module, [DIKey.of(Database)]);
const db = locator.get(DIKey.of(Database));
console.log(db.connected); // true

Parallel Execution: Independent async factories are executed in parallel automatically:

const module = new ModuleDef()
  .make(ServiceA).from().func([], async () => {
    await delay(100);
    return new ServiceA();
  })
  .make(ServiceB).from().func([], async () => {
    await delay(100);
    return new ServiceB();
  });

// ServiceA and ServiceB will be created in parallel (~100ms total, not ~200ms)
await injector.produceAsync(module, [DIKey.of(ServiceA), DIKey.of(ServiceB)]);

Collect multiple implementations into a set:

interface Plugin {
  name: string;
}

@Reflected()
class AuthPlugin implements Plugin {
  name = 'auth';
}

@Reflected()
class LoggingPlugin implements Plugin {
  name = 'logging';
}

@Reflected(Set)
class PluginManager {
  constructor(public readonly plugins: Set<Plugin>) {}
}

const module = new ModuleDef()
  .many(Plugin).from().type(AuthPlugin)
  .many(Plugin).from().type(LoggingPlugin)
  .make(PluginManager).from().type(PluginManager);

Weak set elements are only included if their dependencies can be satisfied:

const module = new ModuleDef()
  .many(Plugin).from().type(CorePlugin)
  .many(Plugin).makeWeak().from().type(OptionalPlugin); // Only included if deps are available

Axis Tagging for Conditional Bindings

Select different implementations based on runtime configuration:

import { Axis, AxisPoint, Activation } from '@izumi-framework/izumi-chibi-ts';

const Environment = Axis.of('Environment', ['Dev', 'Prod']);

const module = new ModuleDef()
  .make(Database)
    .tagged(Environment, 'Dev')
    .from().type(InMemoryDatabase)
  .make(Database)
    .tagged(Environment, 'Prod')
    .from().type(PostgresDatabase)
  .make(UserService).from().type(UserService);

// Use dev database
const devActivation = Activation.of(AxisPoint.of(Environment, 'Dev'));
const devService = injector.produceByType(module, UserService, {
  activation: devActivation
});

// Use prod database
const prodActivation = Activation.of(AxisPoint.of(Environment, 'Prod'));
const prodService = injector.produceByType(module, UserService, {
  activation: prodActivation
});

Manage resources with automatic cleanup:

import { Lifecycle } from '@izumi-framework/izumi-chibi-ts';

class DatabaseConnection {
  async connect() { /* ... */ }
  async disconnect() { /* ... */ }
}

const dbLifecycle = Lifecycle.make(
  async () => {
    const conn = new DatabaseConnection();
    await conn.connect();
    return conn;
  },
  async (conn) => {
    await conn.disconnect();
  }
);

// Use the resource and automatically clean it up
await dbLifecycle.use(async (db) => {
  // Use database
  return await db.query('SELECT * FROM users');
});
// Database is automatically disconnected here, even if an error occurred

Functoid - Dependency Constructors

Functoid represents a function with its dependencies:

import { Functoid } from '@izumi-framework/izumi-chibi-ts';

// Type-safe factory with inference
const functoid1 = Functoid.fromFunction(
  [Database, Config],
  (db, config) => new Service(db, config)
  // Types inferred: db: Database, config: Config
);

// From constructor (with @Reflected)
const functoid2 = Functoid.fromConstructor(MyService);

// Constant value
const functoid3 = Functoid.constant('my-value');

// Manual type specification (when needed)
const functoid4 = Functoid.fromFunctionUnsafe(
  (db, config) => new Service(db, config)
).withTypes([Database, Config]);

distage separates planning (building the dependency graph) from production (instantiating):

const injector = new Injector();

// Plan phase: analyze dependencies, detect errors
const plan = injector.plan(module, [DIKey.of(UserService)]);
console.log(plan.toString()); // View execution plan

// Produce phase: create instances
const locator = injector.produceFromPlan(plan);
const service = locator.get(DIKey.of(UserService));

// Or async
const locator2 = await injector.produceFromPlanAsync(plan);

Locator - Instance Container

The Locator provides access to created instances:

const locator = injector.produce(module, [DIKey.of(UserService)]);

// Get by DIKey
const service = locator.get(DIKey.of(UserService));

// Get set
const plugins = locator.getSet(DIKey.set(Plugin));

// Try to get (returns undefined if not found)
const optional = locator.find(DIKey.of(OptionalService));

// Check if exists
if (locator.has(DIKey.of(Cache))) {
  // ...
}

distage detects common dependency injection errors at planning time:

class Service {
  constructor(public readonly missing: MissingDep) {}
}

const module = new ModuleDef()
  .make(Service).withDeps([MissingDep]).from().type(Service);
  // MissingDep is not bound

const injector = new Injector();
// Throws: MissingDependencyError
injector.produceByType(module, Service);
@Reflected(B)
class A {
  constructor(public readonly b: B) {}
}

@Reflected(A)
class B {
  constructor(public readonly a: A) {}
}

const module = new ModuleDef()
  .make(A).from().type(A)
  .make(B).from().type(B);

// Throws: CircularDependencyError
injector.produceByType(module, A);
const module = new ModuleDef()
  .make(Service).tagged(Env, 'Prod').from().type(ServiceA)
  .make(Service).tagged(Env, 'Prod').from().type(ServiceB); // Same specificity!

// Throws: ConflictingBindingsError
injector.produceByType(module, Service, {
  activation: Activation.of(AxisPoint.of(Env, 'Prod'))
});

Combine and override modules:

const baseModule = new ModuleDef()
  .make(Database).from().type(PostgresDatabase)
  .make(Cache).from().type(RedisCache);

const testModule = new ModuleDef()
  .make(Database).from().type(InMemoryDatabase);

// Merge modules (both bindings kept, testModule takes precedence for conflicts)
const combined = baseModule.append(testModule);
// Synchronous
injector.produce(module, roots, options?)
injector.produceByType(module, type, options?)
injector.produceOne(module, key, options?)

// Asynchronous
await injector.produceAsync(module, roots, options?)
await injector.produceByTypeAsync(module, type, options?)
await injector.produceOneAsync(module, key, options?)

ModuleDef Binding Methods

.make(Type)              // Start a binding
  .named(id)             // Add a name/ID
  .tagged(axis, value)   // Add axis tag
  .from()
    .type(Impl)          // Bind to class
    .value(instance)     // Bind to value
    .func(types, fn)     // Bind to type-safe factory
    .functoid(functoid)  // Bind to Functoid
    .alias(Target)       // Bind to alias

.many(Type)              // Start a set binding
  .makeWeak()            // Make it weak
  .from()
    .type(Impl)          // Add implementation to set
# Enter Nix environment
nix develop

# Install dependencies
npm install

# Build
npm run build

# Run tests
npm test

# Watch mode
npm run test:watch

# Coverage
npm run test:coverage

distage follows distage's architecture:

  1. ModuleDef: DSL for declaring bindings
  2. Planner: Analyzes modules and creates execution plans
    • Resolves which bindings to use based on activation
    • Detects circular and missing dependencies
    • Produces topologically sorted plan
  3. Producer: Executes plans to create instances
    • Creates instances in dependency order
    • Manages singleton semantics
    • Supports parallel async execution
  4. Locator: Provides access to created instances
  5. Injector: Main entry point that coordinates everything

distage implements the core concepts of distage with TypeScript-specific adaptations:

Similarities:

  • Staged DI with Planner/Producer separation
  • Fluent ModuleDef DSL
  • Axis tagging for conditional bindings
  • Set bindings for plugin architectures
  • Functoid abstraction
  • Named dependencies
  • Lifecycle management

Differences:

  • Uses @Reflected decorator for automatic dependency resolution
  • Uses @Id decorator instead of Scala's type tags
  • Type-safe factory functions with parameter type inference
  • Async support with parallel execution
  • Simplified lifecycle management
  • No trait auto-implementation (TypeScript limitation)

Improvements over manual DI:

  • No type duplication with @Reflected and .func()
  • Compile-time validation of dependency types and counts
  • Automatic parallel execution for async factories
  • Early error detection at planning time

MIT

联系我们 contact @ memedata.com