ts-ioc-container
v72.1.0
Published
Fast, lightweight TypeScript dependency injection container with a clean API, scoped lifecycles, decorators, tokens, hooks, lazy injection, customizable providers, and no global container objects.
Downloads
8,379
Maintainers
Keywords
Readme
TypeScript Dependency Injection Container
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, and@hook— lifecycle hook keys are yours to name - can inject properties
- can inject lazy dependencies
- composable provider and registration pipelines
- custom injectors, hooks, and provider behavior
Content
- Setup
- Environment support
- Quickstart
- Cheatsheet
- Container
- Basic usage
- Scope
tags - Instances
- Dispose
- Lazy
lazy - Lazy with registerPipe
lazy()
- Injector
- Provider
provider- Singleton
singleton - Auto resolve
autoResolve - Arguments
appendArgsappendArgsFn - Visibility
visible - Alias
asAlias - Decorator
decorate - On resolve
onResolve
- Singleton
- Registration
@register- Token
bindTo - Scope
scope - Composing decorators
createComposeClassDecorator
- Token
- Module
- Hook
@hook- Hook domains
ScopeHookInjectorHookProviderHook - Construct hooks
onConstructed - Scope disposal hooks
scopeDisposed - Inject Property
- Inject Method
- Hook domains
- Mock
- Error
Setup
npm install ts-ioc-container reflect-metadatayarn add ts-ioc-container reflect-metadataJust put it in the entrypoint file of your project. It should be the first line of the code.
import 'reflect-metadata';And tsconfig.json should have next options:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Environment support
ts-ioc-container ships both CommonJS and ES module builds (selected
automatically via the package exports map) plus TypeScript declarations, so it
runs anywhere modern JavaScript does. The package is sideEffects-free, so
bundlers tree-shake unused exports.
| Environment | Supported | Module format | Notes |
| --- | :---: | --- | --- |
| Node.js (ESM, import) | ✅ | ESM | Node.js 18+; resolved via the import condition. |
| Node.js (CommonJS, require) | ✅ | CJS | Node.js 18+; resolved via the require condition. |
| Browser / Web (via bundlers) | ✅ | ESM | webpack, Vite, Rollup, esbuild, Parcel; tree-shakeable. |
| TypeScript | ✅ | CJS + ESM | Types shipped for both; needs experimentalDecorators for the metadata injector. |
| Deno | ✅ | ESM | Use the npm:ts-ioc-container specifier. |
| Bun | ✅ | CJS + ESM | Runs the native ESM/CJS builds directly. |
[!NOTE] The default
MetadataInjector(and the@inject/@hookdecorators) rely onreflect-metadata. It is declared as an optional peer dependency — install it and import it once at your entrypoint.SimpleInjectorandProxyInjectordo not need it.
Quickstart
import { bindTo, Container, inject, register, Registration as R, singleton, SingleToken, by } 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(by(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 {}thencontainer.addRegistration(R.fromClass(Service)) - Register value:
R.fromValue(config).bindTo('Config') - Register factory:
R.fromFn(({ scope }) => createX(scope)).bindTo('X') - Singleton:
@register(singleton()) - Eager service:
@register(autoResolve())+container.useModule(new AutoResolveModule()) - 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(by(Token))/@inject(by('Key'))/@inject(by(Logger))— the argument is always oneInjectFn,by(...)builds the usual one - Custom inject function:
@inject(({ scope, args }) => ...) - Map an injected value:
@inject(pipe(by('Key'), sanitize(), validate())) - Property inject:
@hook('onInit', injectProp(by('Key'))) - Name a decorator stack:
const repository = (token) => createComposeClassDecorator(register(token, singleton()), addClassMeta('injection-token', () => token))
[!TIP] For classes, prefer the
@register(bindTo('Key'))decorator over the fluentR.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 fluentbindTochain only forR.fromValue(...)andR.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
import 'reflect-metadata';
import { bindTo, Container, type IContainer, inject, register, Registration as R, select, by } 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: '[email protected]', 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(by('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('[email protected]')).toBe(true);
expect(authService.authenticate('[email protected]')).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(by(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.
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
type IContainer,
inject,
register,
Registration as R,
scope,
select,
singleton,
by,
} 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(by(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
import { bindTo, Container, inject, register, Registration as R, select, by } 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(by(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(by(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(by(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
onScopeDisposedhooks, 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.
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 onScopeDisposed 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]
lazyis designed only for class instances resolved from class providers.
[!WARNING] Do not use
lazyfor primitive values, plain values, functions, or non-class provider results.
import 'reflect-metadata';
import { Container, inject, register, Registration as R, select as s, singleton, by } 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(by('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(by(s.token('EmailNotifier').lazy()))
public emailNotifier: EmailNotifier,
) {}
login(email: string, password: string): boolean {
// Most requests just validate credentials - no email needed
return email === '[email protected]' && 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('[email protected]');
// Now SMTP connection was established
expect(result).toBe('Password reset sent to [email protected]');
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('[email protected]');
authService.requestPasswordReset('[email protected]');
// 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:
- With @register decorator: Use
lazy()as a registerPipe in the decorator - With Provider pipe: Use
Provider.fromClass().pipe(lazy())directly
import 'reflect-metadata';
import {
appendArgs,
arg,
bindTo,
Container,
inject,
lazy,
Provider,
register,
Registration as R,
singleton,
by,
} 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(by('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(by('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(by('EmailService')) public email: EmailService,
@inject(by('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('[email protected]', 'Welcome!');
expect(result).toBe('Email sent to [email protected]: 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('[email protected]', '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(by('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(arg(0)) public apiUrl: string,
@inject(arg(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(by('FeatureFlagService')) private flags: FeatureFlagService,
@inject(by('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@injectdecoratorProxyInjector- injects dependencies as dictionaryRecord<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 takes exactly one argument, an InjectFn: (options: ProviderOptions) => T.
It is called with the resolution context of the class being constructed — the
scope it is built in and the runtime args it is built with — and whatever it
returns is injected. There is no other form, only functions which build one:
by(target)— resolves a token, aDependencyKeyor a class from the scope, forwarding the runtime args (see runtime args flow through tokens):@inject(by(LoggerToken)),@inject(by('Logger')),@inject(by(Logger)).arg(index)/args/argsFn(predicate)— pick from the runtime args.pipe(fn, ...mappers)— map what anotherInjectFnreturns.- Your own:
@inject(({ scope }) => scope)injects the current scope,@inject(({ scope }) => Token.resolve(scope))resolves a token without the args.
import { bindTo, Container, inject, register, Registration as R, by } 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(by('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(by('ILogger')) private logger: Logger) {}
// Alternative: inject via function for dynamic resolution
// constructor(@inject(by('ILogger')) 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');
});
});
Mapping injected values
An InjectFn is a function, so mapping the value it resolves is function
composition — pipe(fn, ...mappers) (exported) runs each mapper on the
previous result, left to right, and the value the last one returns is what
reaches the constructor parameter:
@inject(pipe(by<Config>('Config'), takeApiUrl(), stripTrailingSlash(), requireHttps()))A mapper is a plain (value) => value function, so mappers compose into named,
reusable steps (selecting a member, sanitizing, validating) and each step's
parameter type is inferred from the previous one. pipe(fn, ...mappers) is
itself an InjectFn, so it goes wherever one does — @inject(...), a
FunctionToken, or injectProp(pipe(by('Config'), takeApiUrl())).
import 'reflect-metadata';
import { Container, inject, Registration as R, pipe, by } from 'ts-ioc-container';
/**
* Mapping injected values
*
* `@inject` takes one `InjectFn`, so mapping what it resolves is composition:
* `pipe(fn, ...mappers)` applies each mapper to the previous result, left to
* right, and is itself an `InjectFn`. Mappers are plain functions, so they
* compose into reusable, named steps.
*/
interface Config {
apiUrl: string;
retries: number;
}
// Reusable mappers, each returning a `(value) => value` function
const takeApiUrl = () => (config: Config) => config.apiUrl;
const stripTrailingSlash = () => (url: string) => url.replace(/\/$/, '');
const requireHttps = () => (url: string) => {
if (!url.startsWith('https://')) {
throw new Error(`Insecure api url: ${url}`);
}
return url;
};
describe('inject mappers', () => {
it('should pipe the resolved dependency through every mapper', () => {
class ApiClient {
constructor(
@inject(pipe(by<Config>('Config'), takeApiUrl(), stripTrailingSlash(), requireHttps()))
readonly apiUrl: string,
) {}
}
const container = new Container().addRegistration(
R.fromValue<Config>({ apiUrl: 'https://api.com/', retries: 3 }).bindToKey('Config'),
);
expect(container.resolve(ApiClient).apiUrl).toBe('https://api.com');
});
it('should throw from a mapper when the resolved value is not acceptable', () => {
class ApiClient {
constructor(
@inject(pipe(by<Config>('Config'), takeApiUrl(), requireHttps()))
readonly apiUrl: string,
) {}
}
const container = new Container().addRegistration(
R.fromValue<Config>({ apiUrl: 'http://api.com', retries: 3 }).bindToKey('Config'),
);
expect(() => container.resolve(ApiClient)).toThrow('Insecure api url: http://api.com');
});
});
Simple
This type of injector just passes container to constructor with others arguments.
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>.
argsreserved keyword: accessingdeps.argsreturns the rawargs[]array passed at resolve time- Alias convention: any property name containing
"alias"(case-insensitive) is resolved viaresolveByAliasinstead ofresolve
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(({ scope, ...options }) => scope.resolve(Logger, options))
import { arg, 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(arg(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(arg(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
Aof containerroot, thenLoggerofA!==Loggerofroot.
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 = 'random_salt_' + Math.random().toString(36);
}
hash(password: string): string {
return `hashed_${password}_${this.salt}`;
}
verify(password: string, hash: string): boolean {
return this.hash(password) === hash;
}
}
describe('Singleton', function () {
function createAppContainer() {
return new Container({ tags: ['application'] });
}
it('should resolve the same PasswordHasher for every request in same scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Multiple resolves return the same instance
const hasher1 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
expect(hasher1.hash('password')).toBe(hasher2.hash('password'));
});
it('should create different singleton per request scope', function () {
// Application-level singleton
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Each request scope gets its own singleton instance
// This is useful when you want per-request caching
const request1 = appContainer.createScope({ tags: ['request'] });
const request2 = appContainer.createScope({ tags: ['request'] });
const appHasher = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const request1Hasher = request1.resolve<PasswordHasher>('IPasswordHasher');
const request2Hasher = request2.resolve<PasswordHasher>('IPasswordHasher');
// Each scope has its own instance
expect(appHasher).not.toBe(request1Hasher);
expect(request1Hasher).not.toBe(request2Hasher);
});
it('should maintain singleton within a scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Within the same scope, singleton is maintained
const hasher1 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
});
});
Auto resolve
Some services are never injected anywhere - they subscribe to a queue, start a timer, or warm a cache as soon as their scope exists. Lazy resolution would never create them, because nothing asks for them.
@register(autoResolve())marks a provider as eagercontainer.useModule(new AutoResolveModule())enables eager resolution for every scope created afterwardscontainer.autoResolve()resolves the eager providers of one container on demand
Both accept an optional AutoResolveOptions ({ args?: unknown[] }) whose args
are forwarded to every eagerly resolved provider, exactly as resolve forwards
them - so they reach @inject(arg(0)) parameters, scopeAccess rules and the
singleton() cache key. new AutoResolveModule({ args }) applies the same args
to every created scope; call scope.autoResolve({ args }) yourself when the
value differs per scope.
[!IMPORTANT]
autoResolve()on its own does nothing - the container has to opt in withAutoResolveModule. The container the module is applied to is not a created scope, so callcontainer.autoResolve()explicitly to eagerly resolve its own providers.
Eager resolution goes through the provider unchanged, so singleton() still
returns the eagerly created instance later, and providers which are not
registered in the created scope (scope(...)) or deny access to it
(scopeAccess(...)) are skipped.
import 'reflect-metadata';
import {
arg,
autoResolve,
AutoResolveModule,
bindTo,
Container,
type IContainer,
inject,
register,
Registration as R,
scope,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Eager Services
*
* Some services are never injected anywhere: they subscribe to a queue, start a
* timer, or warm a cache as soon as their scope exists. Nothing resolves them,
* so lazy resolution would never create them at all.
*
* `autoResolve()` marks such a provider as eager, and `AutoResolveModule`
* resolves every eager provider of a scope right after the scope is created.
*/
const auditTrail: string[] = [];
const openedLogs: string[] = [];
// Started for every request, even though no other class injects it
@register(bindTo('IRequestAuditor'), scope((s) => s.hasTag('request')), autoResolve(), singleton())
class RequestAuditor {
constructor() {
auditTrail.push('request started');
}
}
// Eager too, but parameterized - the args come from whoever triggers eager resolution
@register(bindTo('IRequestLog'), scope((s) => s.hasTag('request')), autoResolve())
class RequestLog {
constructor(@inject(arg(0)) readonly requestId: string = 'anonymous') {
openedLogs.push(requestId);
}
}
// Resolved on demand, the usual way
@register(bindTo('IUserRepository'), singleton())
class UserRepository {
findById(id: string): string {
return `user_${id}`;
}
}
describe('Auto resolve', function () {
function createAppContainer(options: { args?: unknown[] } = {}): IContainer {
return new Container({ tags: ['application'] })
.useModule(new AutoResolveModule(options))
.addRegistration(R.fromClass(RequestAuditor))
.addRegistration(R.fromClass(RequestLog))
.addRegistration(R.fromClass(UserRepository));
}
beforeEach(() => {
auditTrail.length = 0;
openedLogs.length = 0;
});
it('should create eager services as soon as a scope is created', function () {
const app = createAppContainer();
// Nothing has been created yet - the application container is not a created scope
expect(auditTrail).toEqual([]);
app.createScope({ tags: ['request'] });
app.createScope({ tags: ['request'] });
// One auditor per request scope, without anybody resolving it
expect(auditTrail).toEqual(['request started', 'request started']);
});
it('should reuse the eagerly created instance', function () {
const requestScope = createAppContainer().createScope({ tags: ['request'] });
const auditor = requestScope.resolve<RequestAuditor>('IRequestAuditor');
expect(auditTrail).toEqual(['request started']);
expect(requestScope.resolve<RequestAuditor>('IRequestAuditor')).toBe(auditor);
});
it('should treat resolve options as optional', function () {
createAppContainer().createScope({ tags: ['request'] });
expect(openedLogs).toEqual(['anonymous']);
});
it('should forward args to every eagerly resolved provider', function () {
// The same args reach every scope the module creates...
createAppContainer({ args: ['req-42'] }).createScope({ tags: ['request'] });
expect(openedLogs).toEqual(['req-42']);
// ...or pass a per-scope value by calling autoResolve yourself
const requestScope = createAppContainer().createScope({ tags: ['request'] });
requestScope.autoResolve({ args: ['req-43'] });
expect(openedLogs).toEqual(['req-42', 'anonymous', 'req-43']);
});
it('should leave other providers lazy', function () {
const requestScope = createAppContainer().createScope({ tags: ['request'] });
expect(requestScope.resolve<UserRepository>('IUserRepository').findById('1')).toBe('user_1');
});
});
Arguments
Sometimes you want to bind some arguments to provider.
provider(appendArgs('someArgument'))provider(appendArgsFn(({ scope }) => [scope.resolve(Logger), 'someValue']))Provider.fromClass(Logger).pipe(appendArgs('someArgument'))
Dependencies as arguments
Args are passed to the constructor as-is — the library never resolves an InjectionToken (or a bare constructor) it finds in the args list. Resolve it at the call site with argsFn:
ServiceToken.argsFn((scope) => [ValueToken.resolve(scope)])—ValueTokenis resolved from the container, its value is passed as argServiceToken.argsFn((scope) => [scope.resolve(SomeService)])—SomeServiceis constructed by the containerServiceToken.args('literal')— literal value passed directlyServiceToken.args(ValueToken)— the token object itself is passed as arg
argToToken(value) is the helper for a call site that wants "resolve tokens, pass literals through": it returns an InjectionToken as-is and wraps anything else in a ConstantToken, so @inject(({ scope, args = [] }) => argToToken(args[0]).resolve(scope)) accepts either.
Positional arg injection with arg(index), args, and argsFn
Constructor parameters that should pick up positional args from ProviderOptions must be annotated with @inject(arg(index)). Parameters without @inject resolve to undefined.
@inject(arg(0))— resolves the first element of theargsarray passed at resolution time@inject(args)— resolves the whole runtimeargsarray- Works together with
token.args(...)/token.argsFn(...)to pass typed dependencies through the args context
argsFn(predicate) is the general form: it iterates the runtime args array and returns the first argument matching predicate(value, index) — think args.find(predicate). arg(index) is just a shortcut for matching by position: arg(0) is argsFn((value, index) => index === 0). args is ({ args }) => args, i.e. it returns the runtime args array as-is. Every InjectFn receives one ProviderOptions object — { scope, args, lazy } — where args is the runtime args array.
findOrFail(predicate) is the strict counterpart for singleton() cache keys — singleton(findOrFail(isUserId)) keys a per-argument singleton, for example. It receives the full runtime args array, returns the first argument matching predicate(value), and throws ArgumentNotFoundError when none does, instead of silently handing out undefined.
Runtime args flow through tokens
Every container-backed token (`Single
