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.
| 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 | 
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 
@Iddecorator - 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-tsMake 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'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 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);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' castsUse 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 togetherdistage 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); // trueParallel 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 availableSelect 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 occurredFunctoid 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);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?).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:coveragedistage follows distage's architecture:
- ModuleDef: DSL for declaring bindings
 - Planner: Analyzes modules and creates execution plans
- Resolves which bindings to use based on activation
 - Detects circular and missing dependencies
 - Produces topologically sorted plan
 
 - Producer: Executes plans to create instances
- Creates instances in dependency order
 - Manages singleton semantics
 - Supports parallel async execution
 
 - Locator: Provides access to created instances
 - 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 
@Reflecteddecorator for automatic dependency resolution - Uses 
@Iddecorator 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 
@Reflectedand.func() - Compile-time validation of dependency types and counts
 - Automatic parallel execution for async factories
 - Early error detection at planning time
 
- distage - Scala's staged dependency injection
 - izumi-chibi-py - Python port of distage
 
MIT