@rmk-labs/typescript-dependency-injector
Version:
Dependency injection framework for TypeScript
607 lines (463 loc) • 18.6 kB
Markdown
# TypeScript Dependency Injector
<img src="https://raw.githubusercontent.com/wiki/ets-labs/python-dependency-injector/img/logo.svg" alt="Dependency Injector Logo"/>
TypeScript Dependency Injector (TDI) is a dependency injection framework for TypeScript with declarative container support and decorator-based injection.
Inspired by [Python Dependency Injector](https://github.com/ets-labs/python-dependency-injector), this framework brings similar powerful dependency injection patterns to TypeScript.
## Installation
```bash
npm install @rmk-labs/typescript-dependency-injector
```
## Features
- **Declarative Containers**: Define DI containers using class properties
- **Provider Types**: `Factory` (new instance each time), `Singleton` (shared instance), `Delegate` (inject provider itself)
- **Runtime Context Merging**: Use `Extend` to mix container-managed dependencies with runtime context
- **Type Safety**: Full TypeScript type inference and compile-time checks
- **Decorator-Based Injection**: Optional parameter decorator support with `@Inject`
- **Provider Overriding**: Replace providers at runtime for testing or configuration
- **Well Tested**: Fully covered with comprehensive unit tests
- **Zero Dependencies**: Lightweight with no external dependencies
## Quick Start
```typescript
import {
DeclarativeContainer,
Factory,
Singleton,
createInject,
InstanceOf,
} from "@rmk-labs/typescript-dependency-injector";
// Define your classes
class DatabaseConfig {
constructor(public host: string, public port: number) {}
}
class Database {
constructor(public config: DatabaseConfig) {}
query(sql: string): string {
return `Executing: ${sql}`;
}
}
class UserService {
constructor(private db: Database) {}
getUser(id: number): string {
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
// Create a container
class AppContainer extends DeclarativeContainer {
config = new Factory(DatabaseConfig, "localhost", 5432);
database = new Singleton(Database, this.config);
userService = new Singleton(UserService, this.database);
}
// Create injection decorators
const Inject = createInject({ containerClass: AppContainer });
// Use decorator-based injection
class UserController {
getUser(
id: number,
@Inject.userService service: UserService = InstanceOf(UserService),
): string {
return service.getUser(id);
}
}
// Wire container and use
const container = new AppContainer();
Inject.wire(container);
const controller = new UserController();
controller.getUser(123); // Dependencies injected automatically
```
## Core Concepts
### Providers
Providers are the building blocks that define how dependencies are created:
#### Factory Provider
Creates a new instance every time `provide()` is called:
```typescript
class MyContainer extends DeclarativeContainer {
config = new Factory(DatabaseConfig, "localhost", 5432);
}
const container = new MyContainer();
const config1 = container.config.provide(); // new instance
const config2 = container.config.provide(); // different instance
```
#### Singleton Provider
Returns the same instance on every `provide()` call:
```typescript
class MyContainer extends DeclarativeContainer {
database = new Singleton(Database, this.config);
}
const container = new MyContainer();
const db1 = container.database.provide(); // creates instance
const db2 = container.database.provide(); // returns same instance
```
#### Delegate Provider
Injects the provider itself rather than the provided value. Useful when you need to create instances on demand:
```typescript
import { Provider } from "@rmk-labs/typescript-dependency-injector";
class ConnectionPool {
private connections: Database[] = [];
constructor(private databaseFactory: Provider<Database>) {}
getConnection(): Database {
// Create a new database connection on demand
return this.databaseFactory.provide();
}
}
class MyContainer extends DeclarativeContainer {
config = new Factory(DatabaseConfig, "localhost", 5432);
databaseFactory = new Factory(Database, this.config);
connectionPool = new Singleton(ConnectionPool, this.databaseFactory.provider); // .provider returns Delegate(this.databaseFactory)
}
const container = new MyContainer();
const pool = container.connectionPool.provide();
const conn1 = pool.getConnection(); // Creates new Database instance
const conn2 = pool.getConnection(); // Creates another new Database instance
```
### Dependency Injection Between Providers
Providers automatically resolve dependencies when you pass them as constructor arguments. When a provider is called, it invokes the `provide()` method on any provider arguments, injecting the resolved instances into your classes.
#### Positional Arguments
```typescript
class UserService {
constructor(
private db: Database,
private cache: CacheConfig,
) {}
getUser(id: number): string {
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
class MyContainer extends DeclarativeContainer {
config = new Factory(DatabaseConfig, "localhost", 5432);
cache = new Factory(CacheConfig, 3600, 1000);
database = new Singleton(Database, this.config);
service = new Singleton(UserService, this.database, this.cache);
}
const container = new MyContainer();
const service = container.service.provide();
// What happens under the hood:
// 1. container.service.provide() is called
// 2. The provider resolves dependencies:
// - this.database.provide() → creates/returns Database instance
// - this.cache.provide() → creates CacheConfig instance with (3600, 1000)
// 3. new UserService(databaseInstance, cacheConfigInstance) is called
// 4. UserService is ready with injected dependencies
// Equivalent manual instantiation without DI:
const serviceManual = new UserService(
new Database(
new DatabaseConfig(
"localhost",
5432,
),
),
new CacheConfig(
3600,
1000,
),
);
```
#### Object-Typed Arguments
You can also inject dependencies using object destructuring for better readability:
```typescript
interface ServiceDependencies {
database: Database;
cache: CacheConfig;
logger: Logger;
}
class UserService {
private db: Database;
private cache: CacheConfig;
private logger: Logger;
constructor({ database, cache, logger }: ServiceDependencies) {
this.db = database;
this.cache = cache;
this.logger = logger;
}
getUser(id: number): string {
this.logger.log(`Fetching user ${id}`);
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
class MyContainer extends DeclarativeContainer {
config = new Factory(DatabaseConfig, "localhost", 5432);
cacheConfig = new Factory(CacheConfig, 3600, 1000);
database = new Singleton(Database, this.config);
cache = new Singleton(Cache, this.cacheConfig);
logger = new Singleton(Logger);
// Pass an object with provider properties
service = new Singleton(UserService, {
database: this.database,
cache: this.cache,
logger: this.logger,
});
}
const container = new MyContainer();
const service = container.service.provide();
// What happens under the hood:
// 1. container.service.provide() is called
// 2. The provider resolves the object argument by calling provide() on each property:
// - this.database.provide() → creates/returns Database instance
// - this.cache.provide() → creates/returns Cache instance
// - this.logger.provide() → creates/returns Logger instance
// 3. new UserService({ database: databaseInstance, cache: cacheInstance, logger: loggerInstance }) is called
// 4. UserService is ready with all injected dependencies
// Equivalent manual instantiation without DI:
const serviceManual = new UserService({
database: new Database(
new DatabaseConfig(
"localhost",
5432,
),
),
cache: new Cache(
new CacheConfig(
3600,
1000,
),
),
logger: new Logger(),
});
```
#### Runtime Context with Extend
When you need to provide some dependencies from the container and others at runtime (e.g., request-specific context), use the `Extend` wrapper. This is particularly useful for request-scoped dependencies in web applications or any scenario where you need to mix static dependencies with dynamic context.
```typescript
import { Extend } from "@rmk-labs/typescript-dependency-injector";
interface UserServiceDeps {
logger: Logger;
database: Database;
requestId: string; // Will be provided at runtime
}
class UserService {
private logger: Logger;
private database: Database;
private requestId: string;
constructor(deps: UserServiceDeps) {
this.logger = deps.logger;
this.database = deps.database;
this.requestId = deps.requestId;
}
getUser(id: number): string {
this.logger.log(`[${this.requestId}] Fetching user ${id}`);
return this.database.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
class MyContainer extends DeclarativeContainer {
logger = new Singleton(Logger);
database = new Singleton(Database, "localhost:5432");
// Use Extend to indicate that some properties will come from runtime context
userService = new Factory(UserService, new Extend({
logger: this.logger,
database: this.database,
// requestId will be provided when calling .provide()
}));
}
const container = new MyContainer();
// Provide context at runtime - merges with container defaults
const service = container.userService.provide({ requestId: "req-123" });
service.getUser(42); // Logs: "[Logger] [req-123] Fetching user 42"
// Context values override defaults
const serviceWithCustomLogger = container.userService.provide({
requestId: "req-456",
logger: new CustomLogger(), // Overrides container's logger
});
```
**How Extend Works:**
1. **Defaults in Container**: Define static dependencies (logger, database) in the `Extend` wrapper
2. **Runtime Context**: Pass dynamic values (requestId) to `.provide()`
3. **Smart Merging**: Context values override defaults; default providers are only called for missing keys
4. **Type Safety**: TypeScript ensures all required dependencies are provided either in defaults or context
**Key Benefits:**
- **Performance**: Default providers aren't called for overridden values
- **Flexibility**: Mix container-managed and runtime dependencies
- **Clean Separation**: Static infrastructure vs. dynamic request context
- **Testing**: Easily override dependencies in tests
**Use Cases:**
- Request-scoped dependencies in web applications
- Per-operation context (user ID, tenant ID, request ID)
- A/B testing with different configurations
- Test scenarios with partial mocks
### Decorator-Based Injection
Use parameter decorators for more flexible dependency injection:
```typescript
import { createInject, InstanceOf } from "@rmk-labs/typescript-dependency-injector";
class MyContainer extends DeclarativeContainer {
database = new Singleton(Database, this.config);
logger = new Singleton(Logger);
}
// Create injection markers
const Inject = createInject({ containerClass: MyContainer });
class UserController {
// Method parameter injection
getUser(
id: number,
@Inject.database db: Database = InstanceOf(Database),
@Inject.logger log: Logger = InstanceOf(Logger),
): string {
log.log(`Fetching user ${id}`);
return db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
// Wire container to enable injection
const container = new MyContainer();
Inject.wire(container);
const controller = new UserController();
controller.getUser(123); // Dependencies automatically injected
```
#### Constructor Injection
The `@Inject.Injectable` decorator is required for constructor injections:
```typescript
@Inject.Injectable
class UserService {
constructor(
@Inject.database private db: Database = InstanceOf(Database),
@Inject.logger private log: Logger = InstanceOf(Logger),
) {}
findUser(id: number): string {
this.log.log(`Finding user ${id}`);
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
const container = new MyContainer();
Inject.wire(container);
const service = new UserService(); // Dependencies auto-injected
```
#### Provider Injection
Sometimes you need to inject the provider itself rather than the provided value. This is useful for creating instances on demand, implementing connection pools, or managing resource lifecycles. Use `@Inject.someProvider.provider` to inject the provider:
```typescript
import { Provider, ProviderOf } from "@rmk-labs/typescript-dependency-injector";
class MyContainer extends DeclarativeContainer {
config = new Factory(DatabaseConfig, "localhost", 5432, "myapp");
database = new Factory(Database, this.config); // Factory, not Singleton
logger = new Singleton(Logger);
}
const Inject = createInject({ containerClass: MyContainer });
// Inject the provider to create instances on demand
@Inject.Injectable
class ConnectionPool {
private connections: Database[] = [];
constructor(
@Inject.database.provider private dbProvider: Provider<Database> = ProviderOf(Database),
@Inject.logger private logger: Logger = InstanceOf(Logger)
) {}
getConnection(): Database {
if (this.connections.length > 0) {
this.logger.log("Reusing connection from pool");
return this.connections.pop()!;
}
// Create new connection on demand using the provider
this.logger.log("Creating new connection");
return this.dbProvider.provide();
}
releaseConnection(db: Database): void {
this.connections.push(db);
}
}
const container = new MyContainer();
Inject.wire(container);
const pool = new ConnectionPool();
const conn1 = pool.getConnection(); // Creates new instance
const conn2 = pool.getConnection(); // Creates another new instance
```
**Method Parameter Injection:**
```typescript
class AnalyticsService {
runReports(
@Inject.database.provider dbProvider: Provider<Database> = ProviderOf(Database),
@Inject.logger logger: Logger = InstanceOf(Logger)
): void {
logger.log("Running reports...");
// Create multiple database connections for parallel processing
const reports = ["sales", "users", "activity"];
reports.forEach(report => {
const db = dbProvider.provide(); // New instance for each report
db.query(`GENERATE REPORT ${report}`);
});
}
}
```
**Container-Level Provider Injection:**
You can also use providers directly in your container without decorators:
```typescript
class AppContainer extends DeclarativeContainer {
config = new Factory(DatabaseConfig, "localhost", 5432, "myapp");
database = new Factory(Database, this.config);
logger = new Singleton(Logger);
// Pass database.provider to inject the provider itself (returns a Delegate)
connectionPool = new Singleton(ConnectionPool, this.database.provider, this.logger);
}
const container = new AppContainer();
const pool = container.connectionPool.provide();
// pool can now create database connections on demand
```
## Advanced Features
### Provider Overriding
Override providers for testing or different configurations:
```typescript
const container = new MyContainer();
// Original behavior
const db1 = container.database.provide();
// Override with a mock
const mockDatabase = new Factory(() => new MockDatabase());
container.database.override(mockDatabase);
const db2 = container.database.provide(); // Returns mock
// Reset overrides
container.resetProviderOverrides();
const db3 = container.database.provide(); // Back to original
```
### Singleton Reset
Reset singleton instances to get fresh instances:
```typescript
const container = new MyContainer();
const db1 = container.database.provide();
const db2 = container.database.provide();
console.log(db1 === db2); // true (same instance)
container.resetSingletonInstances();
const db3 = container.database.provide();
console.log(db1 === db3); // false (new instance)
```
### Wire/Unwire
Control when injection is active:
```typescript
const container = new MyContainer();
const Inject = createInject({ containerClass: MyContainer });
Inject.wire(container); // Enable injection
// ... use injected dependencies
Inject.unwire(container); // Disable injection
```
## API Reference
### Classes
- **`DeclarativeContainer`**: Base class for defining DI containers
- **`Factory<T>`**: Provider that creates new instances
- **`Singleton<T>`**: Provider that maintains a single instance
- **`Delegate<T>`**: Provider that returns another provider
- **`BaseProvider<T>`**: Abstract base class for custom providers
- **`Extend<T>`**: Wrapper for object arguments that merges runtime context with container defaults
### Functions
- **`createInject({ containerClass })`**: Creates injection decorators for a container
- **`InstanceOf(Type)`**: Syntax sugar for default parameter values. Returns `undefined` at runtime - the actual injection is done by the decorator
- `InstanceOf(SomeClass)` - for injecting instances
- `InstanceOf<SomeInterface>()` - for injecting instances of interfaces/types
- **`ProviderOf(Type)`**: Syntax sugar for provider injection. Returns `Provider<T>` type
- `ProviderOf(SomeClass)` - for injecting providers that create instances of `SomeClass`
- `ProviderOf<SomeInterface>()` - for injecting providers of interfaces/types
### Container Methods
- **`container.resetProviderOverrides()`**: Resets all provider overrides
- **`container.resetSingletonInstances()`**: Resets all singleton instances
### Inject API Methods
- **`Inject.wire(container)`**: Enable dependency injection
- **`Inject.unwire(container)`**: Disable dependency injection
- **`Inject.Injectable`**: Class decorator for constructor injection
- **`@Inject.propertyName`**: Parameter decorator for each container provider
- **`@Inject.propertyName.provider`**: Parameter decorator to inject the provider itself (returns a `Provider<T>` instance)
## TypeScript Configuration
Enable experimental decorators in your `tsconfig.json`:
```json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": false
}
}
```
## Contributing
Found a bug or have a feature request? Please [open an issue](https://github.com/RMK-Labs/typescript-dependency-injector/issues) on GitHub.
## Support
If you find this project helpful, consider [sponsoring the development](https://github.com/sponsors/rmk135) to help ensure continued maintenance and new features.
## License
BSD-3-Clause
## Repository
[https://github.com/rmk-labs/typescript-dependency-injector](https://github.com/rmk-labs/typescript-dependency-injector)