ts-ioc-container
Version:
Fast, lightweight TypeScript dependency injection container with a clean API, scoped lifecycles, decorators, tokens, hooks, lazy injection, customizable providers, and no global container objects.
1,462 lines (1,155 loc) • 90.6 kB
Markdown
# TypeScript Dependency Injection Container



[](https://coveralls.io/github/IgorBabkin/ts-ioc-container?branch=master)

[](https://github.com/semantic-release/semantic-release)
`ts-ioc-container` is a fast, lightweight TypeScript dependency injection
container for applications that need more than basic constructor injection:
scoped lifecycles, decorators, typed tokens, lazy dependencies, lifecycle hooks,
provider pipelines, aliases, and custom injector strategies.
## Advantages
- fast TypeScript dependency resolution
- lightweight and dependency-minimal
- clean API for classes, keys, tokens, aliases, and scopes
- no global container object; pass containers and scopes explicitly
- supports tagged application, request, transaction, page, and widget scopes
- decorator support with `@register`, `@inject`, `@onConstruct`, and `@onDispose`
- can [inject properties](#inject-property)
- can inject [lazy dependencies](#lazy)
- composable provider and registration pipelines
- custom injectors, hooks, and provider behavior
## Content
- [Setup](#setup)
- [Quickstart](#quickstart)
- [Cheatsheet](#cheatsheet)
- [Container](#container)
- [Basic usage](#basic-usage)
- [Scope](#scope) `tags`
- [Instances](#instances)
- [Dispose](#dispose)
- [Lazy](#lazy) `lazy`
- [Lazy with registerPipe](#lazy-with-registerpipe) `lazy()`
- [Injector](#injector)
- [Metadata](#metadata) `@inject`
- [Simple](#simple)
- [Proxy](#proxy)
- [Provider](#provider) `provider`
- [Singleton](#singleton) `singleton`
- [Arguments](#arguments) `appendArgs` `appendArgsFn`
- [Visibility](#visibility) `visible`
- [Alias](#alias) `asAlias`
- [Decorator](#decorator) `decorate`
- [Registration](#registration) `@register`
- [Token](#token) `bindTo`
- [Scope](#scope) `scope`
- [Module](#module)
- [Hook](#hook) `@hook`
- [OnConstruct](#onconstruct) `@onConstruct`
- [OnDispose](#ondispose) `@onDispose`
- [Inject Property](#inject-property)
- [Inject Method](#inject-method)
- [Mock](#mock)
- [Error](#error)
## Setup
```shell script
npm install ts-ioc-container reflect-metadata
```
```shell script
yarn add ts-ioc-container reflect-metadata
```
Just put it in the entrypoint file of your project. It should be the first line of the code.
```typescript
import 'reflect-metadata';
```
And `tsconfig.json` should have next options:
```json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
```
## Quickstart
```typescript
import { bindTo, Container, inject, register, Registration as R, singleton, SingleToken } from 'ts-ioc-container';
interface ILogger {
log(message: string): void;
}
const ILoggerToken = new SingleToken<ILogger>('ILogger');
@register(bindTo(ILoggerToken), singleton())
class Logger implements ILogger {
log(message: string) {
console.log(message);
}
}
class App {
constructor(@inject(ILoggerToken) private logger: ILogger) {}
start() {
this.logger.log('hello');
}
}
describe('Quickstart', function () {
it('should resolve App with injected Logger', function () {
const container = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const app = container.resolve(App);
app.start();
expect(app).toBeInstanceOf(App);
});
});
```
## Cheatsheet
- Register class with key (preferred): `@register(bindTo('Key')) class Service {}` then `container.addRegistration(R.fromClass(Service))`
- Register value: `R.fromValue(config).bindTo('Config')`
- Register factory: `R.fromFn((c) => createX(c)).bindTo('X')`
- Singleton: `@register(singleton())`
- Scoped registration: `@register(scope((s) => s.hasTag('request')))`
- Resolve by alias: `container.resolveByAlias('Alias')`
- Current scope token: `select.scope.current`
- Lazy token: `select.token('Service').lazy()`
- Inject decorator: `@inject('Key')`
- Property inject: `injectProp(target, 'propName', select.token('Key'))`
> [!TIP]
> For classes, prefer the `@register(bindTo('Key'))` decorator over the fluent
> `R.fromClass(Class).bindTo('Key')` chain. The decorator co-locates the binding
> with the class and reads consistently with other registration pipes
> (`scope`, `singleton`, `appendArgsFn`, ...). Use the fluent `bindTo` chain only
> for `R.fromValue(...)` and `R.fromFn(...)` (which have no class to decorate)
> or for third-party classes you don't own.
## Container
`IContainer` consists of:
- Provider is dependency factory which creates dependency
- Injector describes how to inject dependencies to constructor
- Registration is provider factory which registers provider in container
### Basic usage
```typescript
import 'reflect-metadata';
import { bindTo, Container, type IContainer, inject, register, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Basic Dependency Injection
*
* This example demonstrates how to wire up a simple authentication service
* that depends on a user repository. This pattern is common in web applications
* where services need database access.
*/
describe('Basic usage', function () {
// Domain types
interface User {
id: string;
email: string;
passwordHash: string;
}
// Repository interface - abstracts database access
interface IUserRepository {
findByEmail(email: string): User | undefined;
}
// Concrete implementation
@register(bindTo('IUserRepository'))
class UserRepository implements IUserRepository {
private users: User[] = [{ id: '1', email: 'admin@example.com', passwordHash: 'hashed_password' }];
findByEmail(email: string): User | undefined {
return this.users.find((u) => u.email === email);
}
}
it('should inject dependencies', function () {
// AuthService depends on IUserRepository
class AuthService {
constructor(@inject('IUserRepository') private userRepo: IUserRepository) {}
authenticate(email: string): boolean {
const user = this.userRepo.findByEmail(email);
return user !== undefined;
}
}
// Wire up the container
const container = new Container().addRegistration(R.fromClass(UserRepository));
// Resolve AuthService - UserRepository is automatically injected
const authService = container.resolve(AuthService);
expect(authService.authenticate('admin@example.com')).toBe(true);
expect(authService.authenticate('unknown@example.com')).toBe(false);
});
it('should inject current scope for request context', function () {
// In Express.js, each request gets its own scope
// Services can access the current scope to resolve request-specific dependencies
const appContainer = new Container({ tags: ['application'] });
class RequestHandler {
constructor(@inject(select.scope.current) public requestScope: IContainer) {}
handleRequest(): string {
// Access request-scoped dependencies
return this.requestScope.hasTag('application') ? 'app-scope' : 'request-scope';
}
}
const handler = appContainer.resolve(RequestHandler);
expect(handler.requestScope).toBe(appContainer);
expect(handler.handleRequest()).toBe('app-scope');
});
});
```
### Scope
Sometimes you need to create a scope of container. For example, when you want to create a scope per request in web application. You can assign tags to scope and provider and resolve dependencies only from certain scope.
> [!IMPORTANT]
> Scope creation is snapshot-like. Existing parent registrations are applied to the child scope when `createScope()` is called, and when a scope doesn't have a dependency it resolves from the parent container.
> [!WARNING]
> Registrations added to a parent after a child scope has already been created are not automatically applied to that existing child. Create a new scope or add the registration to the child explicitly.
> [!WARNING]
> Scope matching happens when a registration is applied to a container. Only registrations whose scope rules match that container are registered there.
```typescript
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
type IContainer,
inject,
register,
Registration as R,
scope,
select,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Request Scopes
*
* In web applications, each HTTP request typically gets its own scope.
* This allows request-specific data (current user, request ID, etc.)
* to be isolated between concurrent requests.
*
* Scope hierarchy:
* Application (singleton services)
* └── Request (per-request services)
* └── Transaction (database transaction boundary)
*/
// SessionService is only available in request scope - not at application level
@register(bindTo('ISessionService'), scope((s) => s.hasTag('request')), singleton())
class SessionService {
private userId: string | null = null;
setCurrentUser(userId: string) {
this.userId = userId;
}
getCurrentUserId(): string | null {
return this.userId;
}
}
describe('Scopes', function () {
it('should isolate request-scoped services', function () {
// Application container - lives for entire app lifetime
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(SessionService));
// Simulate two concurrent HTTP requests
const request1Scope = appContainer.createScope({ tags: ['request'] });
const request2Scope = appContainer.createScope({ tags: ['request'] });
// Each request has its own SessionService instance
const session1 = request1Scope.resolve<SessionService>('ISessionService');
const session2 = request2Scope.resolve<SessionService>('ISessionService');
session1.setCurrentUser('user-1');
session2.setCurrentUser('user-2');
// Sessions are isolated - user data doesn't leak between requests
expect(session1.getCurrentUserId()).toBe('user-1');
expect(session2.getCurrentUserId()).toBe('user-2');
expect(session1).not.toBe(session2);
// SessionService is NOT available at application level (security!)
expect(() => appContainer.resolve('ISessionService')).toThrow(DependencyNotFoundError);
});
it('should create child scopes for transactions', function () {
const appContainer = new Container({ tags: ['application'] });
// RequestHandler can create a transaction scope for database operations
class RequestHandler {
constructor(@inject(select.scope.create({ tags: ['transaction'] })) public transactionScope: IContainer) {}
executeInTransaction(): boolean {
// Transaction scope inherits from request scope
// Database operations can be rolled back together
return this.transactionScope.hasTag('transaction');
}
}
const handler = appContainer.resolve(RequestHandler);
expect(handler.transactionScope).not.toBe(appContainer);
expect(handler.transactionScope.hasTag('transaction')).toBe(true);
expect(handler.executeInTransaction()).toBe(true);
});
});
```
### Instances
Sometimes you want to get all instances from container and its scopes. For example, when you want to dispose all instances of container.
- you can get instances from container and scope which were created by injector
```typescript
import { bindTo, Container, inject, register, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Instance Collection
*
* Sometimes you need access to all instances of a certain type:
* - Collect all active database connections for health checks
* - Gather all loggers to flush buffers before shutdown
* - Find all request handlers for metrics collection
*
* The `select.instances()` token resolves all created instances,
* optionally filtered by a predicate function.
*/
describe('Instances', function () {
@register(bindTo('ILogger'))
class Logger {}
it('should collect instances across scope hierarchy', () => {
// App that needs access to all logger instances (e.g., for flushing)
class App {
constructor(@inject(select.instances()) public loggers: Logger[]) {}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Create loggers in different scopes
appContainer.resolve('ILogger');
requestScope.resolve('ILogger');
const appLevel = appContainer.resolve(App);
const requestLevel = requestScope.resolve(App);
// Request scope sees only its own instance
expect(requestLevel.loggers.length).toBe(1);
// Application scope sees all instances (cascades up from children)
expect(appLevel.loggers.length).toBe(2);
});
it('should return only current scope instances when cascade is disabled', () => {
// Only get instances from current scope, not parent scopes
class App {
constructor(@inject(select.instances().cascade(false)) public loggers: Logger[]) {}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const requestScope = appContainer.createScope({ tags: ['request'] });
appContainer.resolve('ILogger');
requestScope.resolve('ILogger');
const appLevel = appContainer.resolve(App);
// Only application-level instance, not request-level
expect(appLevel.loggers.length).toBe(1);
});
it('should filter instances by predicate', () => {
const isLogger = (instance: unknown) => instance instanceof Logger;
class App {
constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}
const container = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const logger0 = container.resolve('ILogger');
const logger1 = container.resolve('ILogger');
const app = container.resolve(App);
expect(app.loggers).toHaveLength(2);
expect(app.loggers[0]).toBe(logger0);
expect(app.loggers[1]).toBe(logger1);
});
});
```
### Dispose
Sometimes you want to dispose a container or scope. For example, when a request, page, widget, or other local lifecycle ends.
- container can be disposed
- when container is disposed then it runs its `onDispose` hooks, unregisters its providers, removes its local instances, and detaches from its parent
> [!IMPORTANT]
> Dispose is local to the container being disposed. Child scopes are not disposed automatically; dispose them explicitly when their own lifecycle ends.
```typescript
import 'reflect-metadata';
import { bindTo, Container, ContainerDisposedError, register, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Resource Cleanup
*
* When a scope ends (e.g., HTTP request completes), resources must be cleaned up:
* - Database connections returned to pool
* - File handles closed
* - Temporary files deleted
* - Cache entries cleared
*
* The container.dispose() method:
* 1. Executes all onDispose hooks
* 2. Clears all instances and registrations
* 3. Detaches from parent scope
* 4. Prevents further resolution
*/
// Simulates a database connection that must be closed
@register(bindTo('IDatabase'))
class DatabaseConnection {
isClosed = false;
query(sql: string): string[] {
if (this.isClosed) {
throw new Error('Connection is closed');
}
return [`Result for: ${sql}`];
}
close(): void {
this.isClosed = true;
}
}
describe('Disposing', function () {
it('should dispose container and prevent further usage', function () {
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(DatabaseConnection));
// Create a request scope with a database connection
const requestScope = appContainer.createScope({ tags: ['request'] });
const connection = requestScope.resolve<DatabaseConnection>('IDatabase');
// Connection works normally
expect(connection.query('SELECT * FROM users')).toEqual(['Result for: SELECT * FROM users']);
// Request ends - dispose the scope
requestScope.dispose();
// Scope is now unusable
expect(() => requestScope.resolve('IDatabase')).toThrow(ContainerDisposedError);
// All instances are cleared
expect(select.instances().resolve(requestScope).length).toBe(0);
// Application container is still functional
expect(appContainer.resolve<DatabaseConnection>('IDatabase')).toBeDefined();
});
it('should clean up request-scoped resources on request end', function () {
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(DatabaseConnection));
// Simulate Express.js request lifecycle
function handleRequest(): { connection: DatabaseConnection; scope: Container } {
const requestScope = appContainer.createScope({ tags: ['request'] }) as Container;
const connection = requestScope.resolve<DatabaseConnection>('IDatabase');
// Do some work...
connection.query('INSERT INTO sessions VALUES (...)');
return { connection, scope: requestScope };
}
// Request 1
const request1 = handleRequest();
expect(request1.connection.isClosed).toBe(false);
// Request 1 ends - in Express, this would be in res.on('finish')
request1.connection.close();
request1.scope.dispose();
// Request 2 gets a fresh connection
const request2 = handleRequest();
expect(request2.connection.isClosed).toBe(false);
expect(request2.connection).not.toBe(request1.connection);
// Cleanup
request2.connection.close();
request2.scope.dispose();
});
});
```
### Lazy
Sometimes you want to create dependency only when somebody want to invoke it's method or property. This is what `lazy` is for.
- Lazy class instances are wrapped in a JavaScript `Proxy`; the real class instance is created on first property or method access.
> [!IMPORTANT]
> `lazy` is designed only for class instances resolved from class providers.
> [!WARNING]
> Do not use `lazy` for primitive values, plain values, functions, or non-class provider results.
```typescript
import 'reflect-metadata';
import { Container, inject, register, Registration as R, select as s, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Lazy Loading
*
* Some services are expensive to initialize:
* - EmailNotifier: Establishes SMTP connection
* - ReportGenerator: Loads templates, initializes PDF engine
* - ExternalApiClient: Authenticates with third-party service
*
* Lazy loading defers instantiation until first use.
* This improves startup time and avoids initializing unused services.
*
* Use cases:
* - Services used only in specific code paths (error notification)
* - Optional features that may not be triggered
* - Breaking circular dependencies
*/
describe('lazy provider', () => {
// Tracks whether SMTP connection was established
@register(singleton())
class SmtpConnectionStatus {
isConnected = false;
connect() {
this.isConnected = true;
}
}
// EmailNotifier is expensive - establishes SMTP connection on construction
class EmailNotifier {
constructor(@inject('SmtpConnectionStatus') private smtp: SmtpConnectionStatus) {
// Simulate expensive SMTP connection
this.smtp.connect();
}
sendPasswordReset(email: string): string {
return `Password reset sent to ${email}`;
}
}
// AuthService might need to send password reset emails
// But most login requests don't need email (only password reset does)
class AuthService {
constructor(@inject(s.token('EmailNotifier').lazy()) public emailNotifier: EmailNotifier) {}
login(email: string, password: string): boolean {
// Most requests just validate credentials - no email needed
return email === 'admin@example.com' && password === 'secret';
}
requestPasswordReset(email: string): string {
// Only here do we actually need the EmailNotifier
return this.emailNotifier.sendPasswordReset(email);
}
}
function createContainer() {
const container = new Container();
container.addRegistration(R.fromClass(SmtpConnectionStatus)).addRegistration(R.fromClass(EmailNotifier));
return container;
}
it('should not connect to SMTP until email is actually needed', () => {
const container = createContainer();
// AuthService is created, but EmailNotifier is NOT instantiated yet
container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// SMTP connection was NOT established - lazy loading deferred it
expect(smtp.isConnected).toBe(false);
});
it('should connect to SMTP only when sending email', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// Trigger password reset - this actually uses EmailNotifier
const result = authService.requestPasswordReset('user@example.com');
// Now SMTP connection was established
expect(result).toBe('Password reset sent to user@example.com');
expect(smtp.isConnected).toBe(true);
});
it('should only create one instance even with multiple method calls', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
// Multiple password resets
authService.requestPasswordReset('user1@example.com');
authService.requestPasswordReset('user2@example.com');
// Only one EmailNotifier instance was created
const emailNotifiers = Array.from(container.getInstances()).filter((x) => x instanceof EmailNotifier);
expect(emailNotifiers.length).toBe(1);
});
it('should trigger instantiation when accessing property on lazy object', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// Just getting the proxy doesn't trigger instantiation
const notifier = authService.emailNotifier;
expect(notifier).toBeDefined();
expect(smtp.isConnected).toBe(false); // Still lazy!
// Accessing a property ON the lazy object triggers instantiation
const method = notifier.sendPasswordReset;
expect(method).toBeDefined();
expect(smtp.isConnected).toBe(true); // Now instantiated!
});
});
```
### Lazy with registerPipe
The `lazy()` registerPipe can be used in two ways: with the `@register` decorator or directly on the `Provider` pipe. This allows you to defer expensive class instance initialization until first access.
**Use cases:**
- Defer expensive initialization (database connections, SMTP, external APIs)
- Conditional features that may not be used
- Breaking circular dependencies
- Memory optimization for optional services
**Two approaches:**
1. **With @register decorator**: Use `lazy()` as a registerPipe in the decorator
2. **With Provider pipe**: Use `Provider.fromClass().pipe(lazy())` directly
```typescript
import 'reflect-metadata';
import {
appendArgs,
args,
bindTo,
Container,
inject,
lazy,
Provider,
register,
Registration as R,
singleton,
} from 'ts-ioc-container';
/**
* Lazy Loading with registerPipe
*
* The lazy() registerPipe can be used in two ways:
* 1. With @register decorator - lazy()
* 2. Directly on provider - provider.lazy()
*
* Both approaches defer instantiation until first access,
* improving startup time and memory usage.
*/
describe('lazy registerPipe', () => {
// Track initialization for testing
const initLog: string[] = [];
beforeEach(() => {
initLog.length = 0;
});
/**
* Example 1: Using lazy() with @register decorator
*
* The lazy() registerPipe defers service instantiation until first use.
* Perfect for expensive services that may not always be needed.
*/
describe('with @register decorator', () => {
// Database connection pool - expensive to initialize
@register(bindTo('DatabasePool'), singleton())
class DatabasePool {
constructor() {
initLog.push('DatabasePool initialized');
}
query(sql: string): string[] {
return [`Results for: ${sql}`];
}
}
// Analytics service - expensive, but only used occasionally
@register(bindTo('AnalyticsService'), lazy(), singleton())
class AnalyticsService {
constructor(@inject('DatabasePool') private db: DatabasePool) {
initLog.push('AnalyticsService initialized');
}
trackEvent(event: string): void {
this.db.query(`INSERT INTO events VALUES ('${event}')`);
}
generateReport(): string {
return 'Analytics Report';
}
}
// Application service - always used
class AppService {
constructor(@inject('AnalyticsService') public analytics: AnalyticsService) {
initLog.push('AppService initialized');
}
handleRequest(path: string): void {
// Most requests don't need analytics
if (path.includes('/admin')) {
// Only admin requests use analytics
this.analytics.trackEvent(`Admin access: ${path}`);
}
}
}
it('should defer AnalyticsService initialization until first access', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
// Resolve AppService
const app = container.resolve<AppService>(AppService);
// AppService is initialized, but AnalyticsService is NOT (it's lazy)
// DatabasePool is also not initialized because AnalyticsService hasn't been accessed
expect(initLog).toEqual(['AppService initialized']);
// Handle non-admin request - analytics not used
app.handleRequest('/api/users');
expect(initLog).toEqual(['AppService initialized']);
});
it('should initialize lazy service when first accessed', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
const app = container.resolve<AppService>(AppService);
// Handle admin request - now analytics IS used
app.handleRequest('/admin/dashboard');
// AnalyticsService was initialized on first access (DatabasePool too, as a dependency)
expect(initLog).toEqual(['AppService initialized', 'DatabasePool initialized', 'AnalyticsService initialized']);
});
it('should create only one instance even with multiple accesses', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
const app = container.resolve<AppService>(AppService);
// Access analytics multiple times
app.handleRequest('/admin/dashboard');
app.analytics.generateReport();
app.analytics.trackEvent('test');
// AnalyticsService initialized only once (singleton + lazy)
const analyticsCount = initLog.filter((msg) => msg === 'AnalyticsService initialized').length;
expect(analyticsCount).toBe(1);
});
});
/**
* Example 2: Using lazy() directly on provider
*
* For manual registration, call .lazy() on the provider pipe.
* This gives fine-grained control over lazy loading per dependency.
*/
describe('with pure provider', () => {
// Email service - expensive SMTP connection
class EmailService {
constructor() {
initLog.push('EmailService initialized - SMTP connected');
}
send(to: string, subject: string): string {
return `Email sent to ${to}: ${subject}`;
}
}
// SMS service - expensive gateway connection
class SmsService {
constructor() {
initLog.push('SmsService initialized - Gateway connected');
}
send(to: string, message: string): string {
return `SMS sent to ${to}: ${message}`;
}
}
// Notification service - uses email and SMS, but maybe not both
class NotificationService {
constructor(
@inject('EmailService') public email: EmailService,
@inject('SmsService') public sms: SmsService,
) {
initLog.push('NotificationService initialized');
}
notifyByEmail(user: string, message: string): string {
return this.email.send(user, message);
}
notifyBySms(phone: string, message: string): string {
return this.sms.send(phone, message);
}
}
it('should allow selective lazy loading - email lazy, SMS eager', () => {
const container = new Container()
// EmailService is lazy - won't connect to SMTP until used
.addRegistration(R.fromClass(EmailService).pipe(singleton(), lazy()))
// SmsService is eager - connects to gateway immediately
.addRegistration(R.fromClass(SmsService).pipe(singleton()))
.addRegistration(R.fromClass(NotificationService));
// Resolve NotificationService
const notifications = container.resolve<NotificationService>(NotificationService);
// SmsService initialized immediately (eager)
// EmailService NOT initialized yet (lazy)
expect(initLog).toEqual(['SmsService initialized - Gateway connected', 'NotificationService initialized']);
// Send SMS - already initialized
notifications.notifyBySms('555-1234', 'Test');
expect(initLog).toEqual(['SmsService initialized - Gateway connected', 'NotificationService initialized']);
});
it('should initialize lazy email service when first accessed', () => {
const container = new Container()
.addRegistration(R.fromClass(EmailService).pipe(singleton(), lazy()))
.addRegistration(R.fromClass(SmsService).pipe(singleton()))
.addRegistration(R.fromClass(NotificationService));
const notifications = container.resolve<NotificationService>(NotificationService);
// Send email - NOW EmailService is initialized
const result = notifications.notifyByEmail('user@example.com', 'Welcome!');
expect(result).toBe('Email sent to user@example.com: Welcome!');
expect(initLog).toContain('EmailService initialized - SMTP connected');
});
it('should work with multiple lazy providers', () => {
const container = new Container()
// Both services are lazy
.addRegistration(R.fromClass(EmailService).pipe(singleton(), lazy()))
.addRegistration(R.fromClass(SmsService).pipe(singleton(), lazy()))
.addRegistration(R.fromClass(NotificationService));
const notifications = container.resolve<NotificationService>(NotificationService);
// Neither service initialized yet
expect(initLog).toEqual(['NotificationService initialized']);
// Use SMS - only SMS initialized
notifications.notifyBySms('555-1234', 'Test');
expect(initLog).toEqual(['NotificationService initialized', 'SmsService initialized - Gateway connected']);
// Use Email - now Email initialized
notifications.notifyByEmail('user@example.com', 'Test');
expect(initLog).toEqual([
'NotificationService initialized',
'SmsService initialized - Gateway connected',
'EmailService initialized - SMTP connected',
]);
});
});
/**
* Example 3: Pure Provider usage (without Registration)
*
* Use Provider.fromClass() directly with lazy() for maximum flexibility.
*/
describe('with pure Provider', () => {
class CacheService {
constructor() {
initLog.push('CacheService initialized - Redis connected');
}
get(key: string): string | null {
return `cached:${key}`;
}
}
class ApiService {
constructor(@inject('CacheService') private cache: CacheService) {
initLog.push('ApiService initialized');
}
fetchData(id: string): string {
const cached = this.cache.get(id);
return cached || `fresh:${id}`;
}
}
it('should use Provider.fromClass with lazy() helper', () => {
// Create pure provider with lazy loading
const cacheProvider = Provider.fromClass(CacheService).lazy().singleton();
const container = new Container();
container.register('CacheService', cacheProvider);
container.addRegistration(R.fromClass(ApiService));
const api = container.resolve<ApiService>(ApiService);
// CacheService not initialized yet (lazy)
expect(initLog).toEqual(['ApiService initialized']);
// Access cache - NOW it's initialized
api.fetchData('user:1');
expect(initLog).toContain('CacheService initialized - Redis connected');
});
it('should allow importing lazy as named export', () => {
// Demonstrate that lazy() is imported from the library
const cacheProvider = Provider.fromClass(CacheService).lazy();
const container = new Container();
container.register('CacheService', cacheProvider);
const cache = container.resolve<CacheService>('CacheService');
// Not initialized until accessed
expect(initLog).toEqual([]);
cache.get('test');
expect(initLog).toEqual(['CacheService initialized - Redis connected']);
});
});
/**
* Example 4: Combining lazy with other pipes
*
* lazy() works seamlessly with other provider transformations.
*/
describe('combining with other pipes', () => {
@register(bindTo('Config'))
class ConfigService {
constructor(
@inject(args(0)) public apiUrl: string,
@inject(args(1)) public timeout: number,
) {
initLog.push(`ConfigService initialized with ${apiUrl}`);
}
}
it('should combine lazy with args and singleton', () => {
const container = new Container().addRegistration(
R.fromClass(ConfigService).pipe(appendArgs('https://api.example.com', 5000), lazy()).pipe(singleton()),
);
// Config not initialized yet
expect(initLog).toEqual([]);
// Resolve - still not initialized (lazy)
const config1 = container.resolve<ConfigService>('Config');
expect(initLog).toEqual([]);
// Access property - NOW initialized
const url = config1.apiUrl;
expect(url).toBe('https://api.example.com');
expect(initLog).toEqual(['ConfigService initialized with https://api.example.com']);
// Resolve again - same instance (singleton)
const config2 = container.resolve<ConfigService>('Config');
expect(config2).toBe(config1);
expect(initLog.length).toBe(1); // Still only one initialization
});
});
/**
* Example 5: Real-world use case - Resource Management
*
* Lazy loading is ideal for:
* - Database connections
* - File handles
* - External API clients
* - Report generators
*/
describe('real-world example - feature flags', () => {
@register(singleton())
class FeatureFlagService {
constructor() {
initLog.push('FeatureFlagService initialized');
}
isEnabled(feature: string): boolean {
return feature === 'premium';
}
}
@register(bindTo('PremiumFeature'), lazy(), singleton())
class PremiumFeature {
constructor() {
initLog.push('PremiumFeature initialized - expensive operation');
}
execute(): string {
return 'Premium feature executed';
}
}
class Application {
constructor(
@inject('FeatureFlagService') private flags: FeatureFlagService,
@inject('PremiumFeature') private premium: PremiumFeature,
) {
initLog.push('Application initialized');
}
handleRequest(feature: string): string {
if (this.flags.isEnabled(feature)) {
return this.premium.execute();
}
return 'Standard feature';
}
}
it('should not initialize premium features for standard users', () => {
const container = new Container()
.addRegistration(R.fromClass(FeatureFlagService))
.addRegistration(R.fromClass(PremiumFeature))
.addRegistration(R.fromClass(Application));
const app = container.resolve<Application>(Application);
// Standard request - premium feature not initialized
const result = app.handleRequest('standard');
expect(result).toBe('Standard feature');
expect(initLog).not.toContain('PremiumFeature initialized - expensive operation');
});
it('should initialize premium features only for premium users', () => {
const container = new Container()
.addRegistration(R.fromClass(FeatureFlagService))
.addRegistration(R.fromClass(PremiumFeature))
.addRegistration(R.fromClass(Application));
const app = container.resolve<Application>(Application);
// Premium request - NOW premium feature is initialized
const result = app.handleRequest('premium');
expect(result).toBe('Premium feature executed');
expect(initLog).toContain('PremiumFeature initialized - expensive operation');
});
});
});
```
## Injector
`IInjector` is used to describe how dependencies should be injected to constructor.
- `MetadataInjector` - injects dependencies using `@inject` decorator
- `ProxyInjector` - injects dependencies as dictionary `Record<string, unknown>`
- `SimpleInjector` - just passes container to constructor with others arguments
### Metadata
This type of injector uses `@inject` decorator to mark where dependencies should be injected. It's bases on `reflect-metadata` package. That's why I call it `MetadataInjector`.
Also you can [inject property.](#inject-property)
```typescript
import { bindTo, Container, inject, register, Registration as R } from 'ts-ioc-container';
/**
* User Management Domain - Metadata Injection
*
* The MetadataInjector (default) uses TypeScript decorators and reflect-metadata
* to automatically inject dependencies into constructor parameters.
*
* How it works:
* 1. @inject('key') decorator marks a parameter for injection
* 2. Container reads metadata at resolution time
* 3. Dependencies are resolved and passed to constructor
*
* This is the most common pattern in Angular, NestJS, and similar frameworks.
* Requires: "experimentalDecorators" and "emitDecoratorMetadata" in tsconfig.
*/
@register(bindTo('ILogger'))
class Logger {
name = 'Logger';
}
class App {
// @inject tells the container which dependency to resolve for this parameter
constructor(@inject('ILogger') private logger: Logger) {}
// Alternative: inject via function for dynamic resolution
// constructor(@inject((container, ...args) => container.resolve('ILogger', ...args)) private logger: ILogger) {}
getLoggerName(): string {
return this.logger.name;
}
}
describe('Metadata Injector', function () {
it('should inject dependencies using @inject decorator', function () {
const container = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
// Container reads @inject metadata and resolves 'ILogger' for the logger parameter
const app = container.resolve(App);
expect(app.getLoggerName()).toBe('Logger');
});
});
```
### Simple
This type of injector just passes container to constructor with others arguments.
```typescript
import { bindTo, Container, type IContainer, register, Registration as R, SimpleInjector } from 'ts-ioc-container';
@register(bindTo('HandlerCreateUser'))
class CreateUserHandler {
handle(username: string): string {
return `User ${username} created`;
}
}
describe('SimpleInjector', function () {
it('should inject container to allow dynamic resolution', function () {
@register(bindTo('Dispatcher'))
class CommandDispatcher {
constructor(private container: IContainer) {}
dispatch(type: string, payload: string): string {
const handler = this.container.resolve<CreateUserHandler>(`Handler${type}`);
return handler.handle(payload);
}
}
const container = new Container({ injector: new SimpleInjector() })
.addRegistration(R.fromClass(CommandDispatcher))
.addRegistration(R.fromClass(CreateUserHandler));
const dispatcher = container.resolve<CommandDispatcher>('Dispatcher');
expect(dispatcher.dispatch('CreateUser', 'alice')).toBe('User alice created');
});
it('should pass additional arguments alongside the container', function () {
class WidgetFactory {
constructor(
private container: IContainer,
private theme: string,
) {}
createWidget(name: string): string {
return `Widget ${name} with ${this.theme} theme (container: ${!!this.container})`;
}
}
const container = new Container({ injector: new SimpleInjector() }).addRegistration(R.fromClass(WidgetFactory));
const factory = container.resolve<WidgetFactory>('WidgetFactory', { args: ['dark'] });
expect(factory.createWidget('Button')).toBe('Widget Button with dark theme (container: true)');
});
});
```
### Proxy
This type of injector injects dependencies as dictionary `Record<string, unknown>`.
- **`args` reserved keyword**: accessing `deps.args` returns the raw `args[]` array passed at resolve time
- **Alias convention**: any property name containing `"alias"` (case-insensitive) is resolved via `resolveByAlias` instead of `resolve`
```typescript
import { bindTo, Container, ProxyInjector, register, Registration as R } from 'ts-ioc-container';
describe('ProxyInjector', function () {
it('should inject dependencies as a props object', function () {
@register(bindTo('logger'))
class Logger {
log(msg: string) {
return `Logged: ${msg}`;
}
}
class UserController {
private logger: Logger;
private prefix: string;
constructor({ logger, prefix }: { logger: Logger; prefix: string }) {
this.logger = logger;
this.prefix = prefix;
}
createUser(name: string): string {
return this.logger.log(`${this.prefix} ${name}`);
}
}
const container = new Container({ injector: new ProxyInjector() })
.addRegistration(R.fromClass(Logger))
.addRegistration(R.fromValue('USER:').bindToKey('prefix'))
.addRegistration(R.fromClass(UserController));
expect(container.resolve<UserController>('UserController').createUser('bob')).toBe('Logged: USER: bob');
});
it('should expose runtime args through the reserved "args" property', function () {
class ReportGenerator {
format: string;
constructor({ args }: { args: string[] }) {
this.format = args[0];
}
generate(): string {
return `Report in ${this.format}`;
}
}
const container = new Container({ injector: new ProxyInjector() }).addRegistration(R.fromClass(ReportGenerator));
const generator = container.resolve<ReportGenerator>('ReportGenerator', { args: ['PDF'] });
expect(generator.generate()).toBe('Report in PDF');
});
});
```
## Provider
Provider is dependency factory which creates dependency.
- `Provider.fromClass(Logger)`
- `Provider.fromValue(logger)`
- `new Provider((container, options) => container.resolve(Logger, options))`
```typescript
import { args, bindTo, Container, inject, lazy, Provider, register, Registration as R } from 'ts-ioc-container';
/**
* Data Processing Pipeline - Provider Patterns
*
* Providers are the recipes for creating objects. This suite demonstrates
* how to customize object creation for a Data Processing Pipeline.
*
* Scenarios:
* - FileProcessor: Created as a class instance
* - Config: Created from a simple value object
* - BatchProcessor: Singleton to coordinate across the app
* - StreamProcessor: Lazy loaded only when needed
*/
class Logger {}
describe('Provider', () => {
it('can be registered as a function (Factory Pattern)', () => {
// dynamic factory
const container = new Container().register('ILogger', new Provider(() => new Logger()));
expect(container.resolve('ILogger')).not.toBe(container.resolve('ILogger'));
});
it('can be registered as a value (Config Pattern)', () => {
// constant value
const config = { maxRetries: 3 };
const container = new Container().register('Config', Provider.fromValue(config));
expect(container.resolve('Config')).toBe(config);
});
it('can be registered as a class (Standard Pattern)', () => {
const container = new Container().register('ILogger', Provider.fromClass(Logger));
expect(container.resolve('ILogger')).toBeInstanceOf(Logger);
});
it('can be featured by fp method (Singleton Pattern)', () => {
// Use ".singleton()" to cache the instance
const appContainer = new Container({ tags: ['application'] }).register(
'SharedLogger',
Provider.fromClass(Logger).singleton(),
);
expect(appContainer.resolve('SharedLogger')).toBe(appContainer.resolve('SharedLogger'));
});
it('can be created from a dependency key (Alias/Redirect Pattern)', () => {
// "LoggerAlias" redirects to "ILogger"
const container = new Container()
.register('ILogger', Provider.fromClass(Logger))
.register('LoggerAlias', Provider.fromKey('ILogger'));
const logger = container.resolve('LoggerAlias');
expect(logger).toBeInstanceOf(Logger);
});
it('supports lazy resolution (Performance Optimization)', () => {
// Logger is not created until accessed
const container = new Container().register('ILogger', Provider.fromClass(Logger));
const lazyLogger = container.resolve('ILogger', { lazy: true });
// It's a proxy, not the real instance yet
expect(typeof lazyLogger).toBe('object');
// Accessing it would trigger creation
});
it('supports args decorator for providing extra arguments', () => {
class FileService {
constructor(@inject(args(0)) readonly basePath: string) {}
}
const container = new Container().register(
'FileService',
Provider.fromClass(FileService).addArgsFn((_, { args = [] } = {}) => [...args, '/var/data']),
);
const service = container.resolve<FileService>('FileService');
expect(service.basePath).toBe('/var/data');
});
it('supports argsFn decorator for dynamic arguments', () => {
class Database {
constructor(@inject(args(0)) readonly connectionString: string) {}
}
const container = new Container().register('DbPath', Provider.fromValue('localhost:5432')).register(
'Database',
// Dynamically resolve connection string at creation time
Provider.fromClass(Database).addArgsFn((scope) => [`postgres://${scope.resolve('DbPath')}`]),
);
const db = container.resolve<Database>('Database');
expect(db.connectionString).toBe('postgres://localhost:5432');
});
it('supports visibility control (Security Pattern)', () => {
// AdminService only visible in admin scope
class AdminService {}
const appContainer = new Container({ tags: ['application'] }).register(
'AdminService',
Provider.fromClass(AdminService).addAccessRule(({ invocationScope }) => invocationScope.hasTag('admin')),
);
const adminScope = appContainer.createScope({ tags: ['admin'] });
const publicScope = appContainer.createScope({ tags: ['public'] });
expect(() => adminScope.resolve('AdminService')).not.toThrow();
expect(() => publicScope.resolve('AdminService')).toThrow();
});
it('allows to register lazy provider via decorator', () => {
let created = false;
@register(bindTo('HeavyService'), lazy())
class HeavyService {
constructor() {
created = true;
}
doWork() {}
}
const container = new Container().addRegistration(R.fromClass(HeavyService));
const service = container.resolve<HeavyService>('HeavyService');
expect(created).toBe(false); // Not created yet
service.doWork(); // Access triggers creation
expect(created).toBe(true);
});
});
```
### Singleton
Sometimes you need to create only one instance of dependency per scope. For example, you want to create only one logger per scope.
- Singleton provider creates only one instance in every scope where it's resolved.
> [!IMPORTANT]
> Singleton means one instance per scope. If you create a scope `A` of container `root`, then `Logger` of `A` !== `Logger` of `root`.
```typescript
import 'reflect-metadata';
import { bindTo, Container, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Singleton Pattern
*
* Singletons are services that should only have one instance per scope.
* Common examples:
* - PasswordHasher: Expensive to initialize (loads crypto config)
* - DatabasePool: Connection pool shared across requests
* - ConfigService: Application configuration loaded once
*
* Note: "singleton" in ts-ioc-container means "one instance per scope",
* not "one instance globally". Each scope gets its own singleton instance.
*/
// PasswordHasher is expensive to create - should be singleton
@register(bindTo('IPasswordHasher'), singleton())
class PasswordHasher {
private readonly salt: string;
constructor() {
// Simulate expensive initialization (loading crypto config, etc.)
this.salt =