Chibi Izumi for TypeScript (distage)
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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| … |
Chibi Izumi for TypeScript (distage)
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
Features
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
Installation
npm install @izumi-framework/izumi-chibi-ts
Make sure to enable the following in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true
}
}
Quick Start
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'
Core Concepts
@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
Named Bindings with @Id
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
Async Support
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)]);
Set Bindings
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 Bindings
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
});
Lifecycle Management
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]);
Planner and Producer
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))) {
// ...
}
Error Detection
distage detects common dependency injection errors at planning time:
Missing Dependencies
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);
Circular Dependencies
@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);
Conflicting Bindings
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'))
});
Module Composition
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);
API Reference
Injector Methods
// 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
Development
Setup
# 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
Architecture
distage 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
Comparison with distage
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
Inspired By
- distage - Scala’s staged dependency injection
- izumi-chibi-py - Python port of distage
License
MIT