TypeScript Dependency Injector (TDI) is a dependency injection framework for TypeScript with declarative container support and decorator-based injection.
Inspired by Python Dependency Injector, this framework brings similar powerful dependency injection patterns to TypeScript.
npm install @rmk-labs/typescript-dependency-injector- 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
Extendto 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
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 automaticallyProviders are the building blocks that define how dependencies are created:
Creates a new instance every time provide() is called:
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 instanceReturns the same instance on every provide() call:
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 instanceInjects the provider itself rather than the provided value. Useful when you need to create instances on demand:
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 instanceProviders 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.
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,
),
);You can also inject dependencies using object destructuring for better readability:
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(),
});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.
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:
- Defaults in Container: Define static dependencies (logger, database) in the
Extendwrapper - Runtime Context: Pass dynamic values (requestId) to
.provide() - Smart Merging: Context values override defaults; default providers are only called for missing keys
- 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
Use parameter decorators for more flexible dependency injection:
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 injectedThe @Inject.Injectable decorator is required for constructor injections:
@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-injectedSometimes 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:
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 instanceMethod Parameter Injection:
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:
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 demandOverride providers for testing or different configurations:
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 originalReset singleton instances to get fresh instances:
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)Control when injection is active:
const container = new MyContainer();
const Inject = createInject({ containerClass: MyContainer });
Inject.wire(container); // Enable injection
// ... use injected dependencies
Inject.unwire(container); // Disable injectionDeclarativeContainer: Base class for defining DI containersFactory<T>: Provider that creates new instancesSingleton<T>: Provider that maintains a single instanceDelegate<T>: Provider that returns another providerBaseProvider<T>: Abstract base class for custom providersExtend<T>: Wrapper for object arguments that merges runtime context with container defaults
createInject({ containerClass }): Creates injection decorators for a containerInstanceOf(Type): Syntax sugar for default parameter values. Returnsundefinedat runtime - the actual injection is done by the decoratorInstanceOf(SomeClass)- for injecting instancesInstanceOf<SomeInterface>()- for injecting instances of interfaces/types
ProviderOf(Type): Syntax sugar for provider injection. ReturnsProvider<T>typeProviderOf(SomeClass)- for injecting providers that create instances ofSomeClassProviderOf<SomeInterface>()- for injecting providers of interfaces/types
container.resetProviderOverrides(): Resets all provider overridescontainer.resetSingletonInstances(): Resets all singleton instances
Inject.wire(container): Enable dependency injectionInject.unwire(container): Disable dependency injectionInject.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 aProvider<T>instance)
Enable experimental decorators in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": false
}
}Found a bug or have a feature request? Please open an issue on GitHub.
If you find this project helpful, consider sponsoring the development to help ensure continued maintenance and new features.
BSD-3-Clause