AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
Fast TypeScript IoC container with a clean API, scoped lifecycles, decorators, tokens, hooks, lazy injection, customizable providers, and no global container objects
Developer toolsTypeScript · JavaScriptGitHub @IgorBabkinOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Full feature, lightweight, dependency injection container
Translation pending
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
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.Read the full README ↗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.
@register, @inject, and @hook — lifecycle hook keys are yours to nametagslazylazy()@injectprovider
singletonautoResolveappendArgs appendArgsFnvisibleasAliasdecorateonResolve@register
bindToscopeEnvcreateComposeClassDecoratorMultiVariantFeatureToken ToggleFeatureToken@hook
ScopeHook InjectorHook ProviderHookonConstructedscopeDisposednpm install ts-ioc-container reflect-metadata
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.
import 'reflect-metadata';
And tsconfig.json should have next options:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
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.
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);
});
});
@register(bindTo('Key')) class Service {} then container.addRegistration(R.fromClass(Service))R.fromValue(config).bindTo('Config')R.fromFn(({ scope }) => createX(scope)).bindTo('X')@register(singleton())@register(autoResolve()) + container.useModule(new AutoResolveModule())@register(scope((s) => s.hasTag('request')))container.resolveByAlias('Alias')select.scope.currentselect.token('Service').lazy()@inject(by(Token)) / @inject(by('Key')) / @inject(by(Logger)) — the argument is always one InjectFn, by(...) builds the usual one@inject(({ scope, args }) => ...)@inject(pipe(by('Key'), sanitize(), validate()))@hook('onInit', injectProp(by('Key')))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.
IContainer consists of:
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');
});
});
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);
});
});
Sometimes you want to get all instances from container and its scopes. For example, when you want to dispose all instances of container.
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);
});
});
Sometimes you want to dispose a container or scope. For example, when a request, page, widget, or other local lifecycle ends.
onScopeDisposed 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.
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();
});
});
Sometimes you want to create dependency only when somebody want to invoke it's method or property. This is what lazy is for.
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!
});
});
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:
Two approaches:
lazy() as a registerPipe in the decoratorProvider.fromClass().pipe(lazy()) directlyimport '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');
});
});
});
IInjector is used to describe how dependencies should be injected to constructor.
MetadataInjector - injects dependencies using @inject decoratorProxyInjector - injects dependencies as dictionary Record<string, unknown>SimpleInjector - just passes container to constructor with others argumentsThis 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, a DependencyKey or 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.byArgs(pick) — resolve the token pick finds among the runtime args.pipe(fn, ...mappers) — map what another InjectFn returns.@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');
});
});
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');
});
});
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)');
});
});
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" (case-insensitive) is resolved via resolveByAlias instead of resolveimport { 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 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);
});
});
Sometimes you need to create only one instance of dependency per scope. For example, you want to create only one logger per scope.
[!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);
});
});
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 demandBoth 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');
});
});
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'))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)]) — ValueToken is resolved from the container, its value is passed as argServiceToken.argsFn((scope) => [scope.resolve(SomeService)]) — SomeService is constructed by the containerServiceToken.args('literal') — literal value passed directlyServiceToken.args(ValueToken) — the token object itself is passed as argargToToken(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.
byArgs(pick) is the InjectFn for a class whose dependency is chosen by the caller: the caller passes the dependency's token as a runtime arg, pick finds it in the args list, and byArgs resolves it in the current scope (without args). It pairs with findArgOrFail, which throws ArgumentNotFoundError when no arg matches — and the same picker keys the singleton(), so there is one instance per chosen dependency:
import {
ArgumentNotFoundError,
byArgs,
Container,
findArgOrFail,
inject,
type InjectionToken,
register,
Registration as R,
singleton,
SingleToken,
} from 'ts-ioc-container';
interface IRepository {
name: string;
}
// a repository token carries its own marker, so it can be found among the runtime args
class RepositoryToken<T extends IRepository> extends SingleToken<T> {}
const isRepositoryToken = (value: unknown): value is InjectionToken<IRepository> => value instanceof RepositoryToken;
const repositoryTokenOf = findArgOrFail<InjectionToken<IRepository>>(isRepositoryToken);
const IUserRepositoryToken = new RepositoryToken<IRepository>('IUserRepository');
const IOrderRepositoryToken = new RepositoryToken<IRepository>('IOrderRepository');
const IEntityManagerToken = new SingleToken<EntityManager>('IEntityManager');
@register(IUserRepositoryToken)
class UserRepository implements IRepository {
name = 'users';
}
@register(IOrderRepositoryToken)
class OrderRepository implements IRepository {
name = 'orders';
}
// the caller chooses the repository; one entity manager per repository token
@register(IEntityManagerToken, singleton(repositoryTokenOf))
class EntityManager {
constructor(@inject(byArgs(repositoryTokenOf)) readonly repository: IRepository) {}
}
describe('byArgs', function () {
const createContainer = () =>
new Container()
.addRegistration(R.fromClass(UserRepository))
.addRegistration(R.fromClass(OrderRepository))
.addRegistration(R.fromClass(EntityManager));
it('should resolve the token the caller passes as a runtime arg', function () {
const container = createContainer();
const users = IEntityManagerToken.resolve(container, { args: [IUserRepositoryToken] });
const orders = IEntityManagerToken.resolve(container, { args: [IOrderRepositoryToken] });
expect(users.repository.name).toBe('users');
expect(orders.repository.name).toBe('orders');
expect(IEntityManagerToken.resolve(container, { args: [IUserRepositoryToken] })).toBe(users);
});
it('should throw when no runtime arg matches', function () {
const container = createContainer();
expect(() => IEntityManagerToken.resolve(container, { args: ['not a token'] })).toThrow(ArgumentNotFoundError);
});
});
arg(index), args, and argsFnConstructor 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 the args array passed at resolution time@inject(args) — resolves the whole runtime args arraytoken.args(...) / token.argsFn(...) to pass typed dependencies through the args contextargsFn(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.
findArgOrFail(predicate) is the strict counterpart for singleton() cache keys — singleton(findArgOrFail(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.
Every container-backed token (SingleToken, ClassToken, SingleAliasToken, GroupAliasToken, FunctionToken) forwards the args of its own resolve call to the provider, exactly like container.resolve(key, { args }) does. token.args(...) and token.argsFn(...) append after the runtime args, so token.args('x').resolve(container, { args: ['r'] }) hands the provider ['r', 'x'].
Every @inject function is called with the args of the class being constructed, so an InjectFn which hands them on — by(Token) does, being ({ scope, ...options }) => Token.resolve(scope, options) — cascades them into the injected dependency: they reach the dependency's provider, its @inject(arg(index)) parameters, its scopeAccess rule and its singleton() cache key. One that does not — ({ scope }) => Token.resolve(scope) — resolves the dependency with no args at all; which one a parameter wants is written at the parameter. The cascade is what lets a per-user UserService share a per-user UserRepository without passing the id along by hand:
import {
arg,
bindTo,
Container,
findArgOrFail,
inject,
register,
Registration as R,
singleton,
SingleToken,
by,
} from 'ts-ioc-container';
interface IUserRepository {
userId: string;
}
const IUserRepositoryKey = new SingleToken<IUserRepository>('IUserRepository');
const isUserId = (value: unknown): value is string => typeof value === 'string';
// one repository per user id - the id is the singleton cache key
@register(bindTo(IUserRepositoryKey), singleton(findArgOrFail<string>(isUserId)))
class UserRepository implements IUserRepository {
constructor(@inject(arg(0)) public userId: string) {}
}
class UserService {
constructor(@inject(by(IUserRepositoryKey)) public repository: IUserRepository) {}
}
describe('Token Runtime Arguments', function () {
it('should forward runtime args to the provider behind the token', function () {
const container = new Container().addRegistration(R.fromClass(UserRepository));
expect(IUserRepositoryKey.resolve(container, { args: ['user-1'] }).userId).toBe('user-1');
});
it('should cascade the runtime args of a class into its injected dependencies', function () {
const container = new Container().addRegistration(R.fromClass(UserRepository));
const service = container.resolve(UserService, { args: ['user-1'] });
const sameUser = container.resolve(UserService, { args: ['user-1'] });
const otherUser = container.resolve(UserService, { args: ['user-2'] });
expect(service.repository.userId).toBe('user-1');
expect(sameUser.repository).toBe(service.repository);
expect(otherUser.repository.userId).toBe('user-2');
});
});
[!IMPORTANT] Runtime args come first. A dependency that reads
@inject(arg(0))sees the caller's first runtime arg whenever the caller was resolved with args, even if its token was specialized withtoken.args(...). Pick args by shape (argsFn(predicate),findArgOrFail(predicate)) rather than by position when a class can be resolved with runtime args and its dependencies are specialized withtoken.args(...).
token.args(...), token.argsFn(...), and token.lazy() all return new token instances — the parent token is never mutated. This allows the same token to be specialized in multiple independent ways (one-way linked list: parent → many children).
const ApiToken = new SingleToken<IApiClient>('IApiClient');
// Independent children — ApiToken is unchanged
const dataToken = ApiToken.args('https://data.api.com', 5000);
const userToken = ApiToken.args('https://users.api.com', 1000);
import {
arg,
appendArgs,
appendArgsFn,
bindTo,
Container,
inject,
register,
Registration as R,
SingleToken,
singleton,
by,
} from 'ts-ioc-container';
/**
* Advanced - Arguments Provider
*
* You can inject arguments into providers at registration time or resolution time.
* This is powerful for:
* - Configuration injection
* - Factory patterns
* - Generic classes (like Repositories) that need to know what they are managing
*/
describe('IProvider', function () {
function createContainer() {
return new Container();
}
describe('Static Arguments', () => {
it('can pass static arguments to constructor', function () {
// Pre-configure the logger with a filename
@register(appendArgs('/var/log/app.log'))
class FileLogger {
constructor(@inject(arg(0)) public filename: string) {}
}
const root = createContainer().addRegistration(R.fromClass(FileLogger));
// Resolve by class name (default key) to use the registered provider
const logger = root.resolve<FileLogger>('FileLogger');
expect(logger.filename).toBe('/var/log/app.log');
});
it('appends configured args after resolve args', function () {
@register(appendArgs('ConfiguredContext'))
class Logger {
constructor(
@inject(arg(0)) public runtimeContext: string,
@inject(arg(1)) public configuredContext: string,
) {}
}
const root = createContainer().addRegistration(R.fromClass(Logger));
const logger = root.resolve<Logger>('Logger', { args: ['RuntimeContext'] });
expect(logger.runtimeContext).toBe('RuntimeContext');
expect(logger.configuredContext).toBe('ConfiguredContext');
});
});
describe('Dynamic Arguments (Factory)', () => {
it('can resolve arguments dynamically from container', function () {
class Config {
env = 'production';
}
// Extract 'env' from Config service dynamically
@register(appendArgsFn(({ scope }) => [scope.resolve<Config>('Config').env]))
class Service {
constructor(@inject(arg(0)) public env: string) {}
}
const root = createContainer()
.addRegistration(R.fromClass(Config)) // Key: 'Config'
.addRegistration(R.fromClass(Service));
const service = root.resolve<Service>('Service');
expect(service.env).toBe('production');
});
});
describe('Appending Arguments', () => {
it('can append static arguments after existing resolve arguments', function () {
@register(appendArgs('configured'))
class Service {
constructor(
@inject(arg(0)) public runtime: string,
@inject(arg(1)) public configured: string,
) {}
}
const root = createContainer().addRegistration(R.fromClass(Service));
const service = root.resolve<Service>('Service', { args: ['runtime'] });
expect(service.runtime).toBe('runtime');
expect(service.configured).toBe('configured');
});
it('can append dynamic arguments after runtime args', function () {
class Config {
tenant = 'tenant-a';
}
@register(appendArgs('fixed'), appendArgsFn(({ scope }) => [scope.resolve<Config>('Config').tenant]))
class Service {
constructor(
@inject(arg(0)) public runtime: string,
@inject(arg(1)) public fixed: string,
@inject(arg(2)) public tenant: string,
) {}
}
const root = createContainer().addRegistration(R.fromClass(Config)).addRegistration(R.fromClass(Service));
const service = root.resolve<Service>('Service', { args: ['runtime'] });
expect(service.runtime).toBe('runtime');
expect(service.fixed).toBe('fixed');
expect(service.tenant).toBe('tenant-a');
});
});
describe('Generic Repositories (Advanced Pattern)', () => {
// This example demonstrates how to implement the Generic Repository pattern
// where a generic EntityManager needs to know WHICH repository to use.
interface IRepository {
name: string;
}
// Tokens for specific repository types
const UserRepositoryToken = new SingleToken<IRepository>('UserRepository');
const TodoRepositoryToken = new SingleToken<IRepository>('TodoRepository');
@register(bindTo(UserRepositoryToken))
class UserRepository implements IRepository {
name = 'UserRepository';
}
@register(bindTo(TodoRepositoryToken))
class TodoRepository implements IRepository {
name = 'TodoRepository';
}
// EntityManager is generic - it works with ANY repository.
// The repository is the first arg; `@inject(arg(0))` reads it. Args are
// passed through as-is, so the call site resolves the repository token
// itself with `argsFn` before it reaches the constructor.
const EntityManagerToken = new SingleToken<EntityManager>('EntityManager');
const withRepository = (token: SingleToken<IRepository>) =>
EntityManagerToken.argsFn((scope) => [token.resolve(scope)]);
@register(
bindTo(EntityManagerToken),
singleton(([repository]) => (repository as IRepository).name), // Cache unique instance per repository type
)
class EntityManager {
constructor(@inject(arg(0)) public repository: IRepository) {}
}
class App {
constructor(
// Inject EntityManager configured for Users
@inject(by(withRepository(UserRepositoryToken)))
public userManager: EntityManager,
// Inject EntityManager configured for Todos
@inject(by(withRepository(TodoRepositoryToken)))
public todoManager: EntityManager,
) {}
}
it('should create specialized instances based on token arguments', function () {
const root = createContainer()
.addRegistration(R.fromClass(EntityManager))
.addRegistration(R.fromClass(UserRepository))
.addRegistration(R.fromClass(TodoRepository));
const app = root.resolve(App);
expect(app.userManager.repository).toBeInstanceOf(UserRepository);
expect(app.todoManager.repository).toBeInstanceOf(TodoRepository);
});
it('should cache specialized instances separately', function () {
const root = createContainer()
.addRegistration(R.fromClass(EntityManager))
.addRegistration(R.fromClass(UserRepository))
.addRegistration(R.fromClass(TodoRepository));
// Resolve user manager twice
const userManager1 = withRepository(UserRepositoryToken).resolve(root);
const userManager2 = withRepository(UserRepositoryToken).resolve(root);
// Should be same instance (cached)
expect(userManager1).toBe(userManager2);
// Resolve todo manager
const todoManager = withRepository(TodoRepositoryToken).resolve(root);
// Should be different from user manager
expect(todoManager).not.toBe(userManager1);
});
});
});
Sometimes you want to hide dependency if somebody wants to resolve it from certain scope. This uses ScopeAccessRule to control access.
[!IMPORTANT] Use
scope()to decide where a provider is registered. UsescopeAccess()to decide which invocation scopes can see an already registered provider.
provider(scopeAccess(({ invocationScope, providerScope }) => invocationScope === providerScope)) - dependency will be accessible only from the scope where it's registeredProvider.fromClass(Logger).pipe(scopeAccess(({ invocationScope, providerScope }) => invocationScope === providerScope))Rules compose as a left-fold starting from true — each rule receives the accumulated result of all previous rules, enabling AND/OR logic across multiple scopeAccess calls.
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
register,
Registration as R,
scope,
scopeAccess,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Visibility Control
*
* Some services should only be accessible in specific scopes:
* - AdminService: Only accessible in admin routes
* - AuditLogger: Only accessible at application level (not per-request)
* - DebugService: Only accessible in development environment
*
* scopeAccess() controls VISIBILITY - whether a registered service
* can be resolved from a particular scope.
*
* This provides security-by-design:
* - Prevents accidental access to sensitive services
* - Enforces architectural boundaries
* - Catches misuse at resolution time (not runtime)
*/
describe('Visibility', function () {
it('should restrict admin services to admin routes only', () => {
// UserManagementService can delete users - admin only!
@register(
bindTo('IUserManagement'),
scope((s) => s.hasTag('application')), // Registered at app level
singleton(),
// Only accessible from admin scope, not regular request scope
scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin')),
)
class UserManagementService {
deleteUser(userId: string): string {
return `Deleted user ${userId}`;
}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(UserManagementService));
// Admin route scope
const adminScope = appContainer.createScope({ tags: ['request', 'admin'] });
// Regular user route scope
const userScope = appContainer.createScope({ tags: ['request', 'user'] });
// Admin can access UserManagementService
const adminService = adminScope.resolve<UserManagementService>('IUserManagement');
expect(adminService.deleteUser('user-123')).toBe('Deleted user user-123');
// Regular users cannot access it - security enforced at DI level
expect(() => userScope.resolve('IUserManagement')).toThrowError(DependencyNotFoundError);
});
it('should restrict application-level services from request scope', () => {
// AuditLogger should only be used at application initialization
// Not from request handlers (to prevent log corruption from concurrent access)
@register(
bindTo('IAuditLogger'),
scope((s) => s.hasTag('application')),
singleton(),
// Only accessible from the scope where it was registered
scopeAccess(({ invocationScope, providerScope }) => invocationScope === providerScope),
)
class AuditLogger {
log(message: string): string {
return `AUDIT: ${message}`;
}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(AuditLogger));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Application can use AuditLogger (for startup logging)
expect(appContainer.resolve<AuditLogger>('IAuditLogger').log('App started')).toBe('AUDIT: App started');
// Request handlers cannot access it directly
expect(() => requestScope.resolve('IAuditLogger')).toThrowError(DependencyNotFoundError);
});
});
Alias is needed to group keys
@register(asAlias('logger')) helper assigns logger alias to registration.by.aliases((it) => it.has('logger') || it.has('a')) resolves dependencies which have logger or a aliasesProvider.fromClass(Logger).pipe(alias('logger'))import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
inject,
register,
Registration as R,
scope,
select as s,
by,
} from 'ts-ioc-container';
/**
* User Management Domain - Alias Pattern (Multiple Implementations)
*
* Aliases allow multiple services to be registered under the same key.
* This is useful for:
* - Plugin systems (multiple notification channels)
* - Strategy pattern (multiple authentication providers)
* - Event handlers (multiple listeners for same event)
*
* Example: NotificationService with Email, SMS, and Push implementations
*/
describe('alias', () => {
// All notification services share this alias
const INotificationChannel = 'INotificationChannel';
const notificationChannel = register(bindTo(s.alias(INotificationChannel)));
interface INotificationChannel {
send(userId: string, message: string): void;
getDeliveredMessages(): string[];
}
// Email notification - always available
@notificationChannel
class EmailNotifier implements INotificationChannel {
private delivered: string[] = [];
send(userId: string, message: string): void {
this.delivered.push(`EMAIL to ${userId}: ${message}`);
}
getDeliveredMessages(): string[] {
return this.delivered;
}
}
// SMS notification - for urgent messages
@notificationChannel
class SmsNotifier implements INotificationChannel {
private delivered: string[] = [];
send(userId: string, message: string): void {
this.delivered.push(`SMS to ${userId}: ${message}`);
}
getDeliveredMessages(): string[] {
return this.delivered;
}
}
it('should notify through all channels', () => {
// NotificationManager broadcasts to ALL registered channels
class NotificationManager {
constructor(
@inject(by(s.alias(INotificationChannel)))
private channels: INotificationChannel[],
) {}
notifyUser(userId: string, message: string): void {
for (const channel of this.channels) {
channel.send(userId, message);
}
}
getChannelCount(): number {
return this.channels.length;
}
}
const container = new Container()
.addRegistration(R.fromClass(EmailNotifier))
.addRegistration(R.fromClass(SmsNotifier));
const manager = container.resolve(NotificationManager);
manager.notifyUser('user-123', 'Your password was reset');
// Both channels received the message
expect(manager.getChannelCount()).toBe(2);
});
it('should resolve single implementation by alias', () => {
// Sometimes you only need one implementation (e.g., primary email service)
@register(bindTo(s.alias('IPrimaryNotifier')))
class PrimaryEmailNotifier {
readonly type = 'email';
}
const container = new Container().addRegistration(R.fromClass(PrimaryEmailNotifier));
// resolveOneByAlias returns first matching implementation
const notifier = container.resolveOneByAlias<PrimaryEmailNotifier>('IPrimaryNotifier');
expect(notifier.type).toBe('email');
// Direct key resolution fails - only alias is registered
expect(() => container.resolve('IPrimaryNotifier')).toThrowError(DependencyNotFoundError);
});
it('should use different implementations per scope', () => {
// Development: Console logger for easy debugging
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('development')))
class ConsoleLogger {
readonly type = 'console';
}
// Production: Database logger for audit trail
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('production')))
class DatabaseLogger {
readonly type = 'database';
}
// Development environment
const devContainer = new Container({ tags: ['development'] })
.addRegistration(R.fromClass(ConsoleLogger))
.addRegistration(R.fromClass(DatabaseLogger));
// Production environment
const prodContainer = new Container({ tags: ['production'] })
.addRegistration(R.fromClass(ConsoleLogger))
.addRegistration(R.fromClass(DatabaseLogger));
const devLogger = devContainer.resolveOneByAlias<ConsoleLogger | DatabaseLogger>('ILogger');
const prodLogger = prodContainer.resolveOneByAlias<ConsoleLogger | DatabaseLogger>('ILogger');
expect(devLogger.type).toBe('console');
expect(prodLogger.type).toBe('database');
});
});
Sometimes you want to decorate you class with some logic. Use the decorate(...) pipe — it appends a DecorateFn to the provider's mapper chain via IProvider.map(...).
provider(decorate((instance, container) => new LoggerDecorator(instance)))import {
arg,
bindTo,
Container,
decorate,
type IContainer,
inject,
register,
Registration as R,
select as s,
singleton,
by,
} from 'ts-ioc-container';
/**
* User Management Domain - Decorator Pattern
*
* The decorator pattern wraps a service with additional behavior:
* - Logging: Log all repository operations for audit
* - Caching: Cache results of expensive operations
* - Retry: Automatically retry failed operations
* - Validation: Validate inputs before processing
*
* In DI, decorators are applied at registration time, so consumers
* get the decorated version without knowing about the decoration.
*
* This example shows a TodoRepository decorated with logging -
* every save operation is automatically logged.
*/
describe('Decorator Pattern', () => {
// Singleton logger collects all log entries
@register(singleton())
class Logger {
private logs: string[] = [];
log(message: string) {
this.logs.push(message);
}
printLogs() {
return this.logs.join(',');
}
}
interface IRepository {
save(item: Todo): Promise<void>;
}
interface Todo {
id: string;
text: string;
}
// Decorator: Wraps any IRepository with logging behavior
class LoggingRepository implements IRepository {
constructor(
@inject(arg(0)) private repository: IRepository,
@inject(by(s.token('Logger').lazy())) private logger: Logger,
) {}
async save(item: Todo): Promise<void> {
// Log the operation
this.logger.log(item.id);
// Delegate to the wrapped repository
return this.repository.save(item);
}
}
// Decorator factory - creates LoggingRepository wrapping the original
const withLogging = (repository: IRepository, scope: IContainer) =>
scope.resolve(LoggingRepository, { args: [repository] });
// TodoRepository is automatically decorated with logging
@register(bindTo('IRepository'), decorate(withLogging))
class TodoRepository implements IRepository {
async save(item: Todo): Promise<void> {
// Actual database save logic would go here
}
}
class App {
constructor(@inject(by('IRepository')) public repository: IRepository) {}
async run() {
await this.repository.save({ id: '1', text: 'Buy groceries' });
await this.repository.save({ id: '2', text: 'Walk the dog' });
}
}
function createAppContainer() {
return new Container({ tags: ['application'] })
.addRegistration(R.fromClass(TodoRepository))
.addRegistration(R.fromClass(Logger));
}
it('should automatically log all repository operations via decorator', async () => {
const container = createAppContainer();
const app = container.resolve(App);
const logger = container.resolve<Logger>('Logger');
// App uses repository normally - unaware of logging decorator
await app.run();
// All operations were logged transparently
expect(logger.printLogs()).toBe('1,2');
});
});
Sometimes you don't want to change the dependency, only to react to it. Use the onResolve(...) pipe — it appends a ProviderHook that receives the resolved dependency and the resolving scope.
provider(onResolve((instance, scope) => tracker.track(instance)))Hooks run after the whole decorate(...) chain, so they always observe the fully decorated dependency, and their return value is ignored — onResolve can never swap the dependency out. They fire per resolution, which means a singleton() provider runs them only on the resolve that fills the cache.
This — or a hook key collected over the same provider event — is the recommended way to react to a dependency; see Construct hooks for why construction is the narrower event.
import 'reflect-metadata';
import { Container, type IContainer, onResolve, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* Observability Domain - onResolve hooks
*
* `onResolve(...)` attaches side effects to a provider. Every time the provider
* hands a dependency back, each hook is called with that dependency and the
* resolving scope.
*
* Unlike `decorate(...)`, a hook cannot replace the dependency - its return
* value is ignored. Use `decorate` to change what the caller gets, and
* `onResolve` to react to what the caller got: tracking, metrics, registering
* the instance with an external bus.
*
* Hooks always run after the whole `decorate` chain, so they observe the fully
* decorated dependency no matter where `onResolve` sits in the pipe list.
*/
describe('onResolve', () => {
it('should observe every resolved dependency without changing it', () => {
const resolved: Array<{ name: string; fromRequest: boolean }> = [];
const track = (dependency: unknown, scope: IContainer) => {
resolved.push({ name: (dependency as Connection).name, fromRequest: scope.hasTag('request') });
};
@register(onResolve(track))
class Connection {
readonly name = 'Connection';
}
const app = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Connection));
const request = app.createScope({ tags: ['request'] });
const connection = request.resolve<Connection>('Connection');
// The caller still receives the untouched instance
expect(connection).toBeInstanceOf(Connection);
// ...and the hook saw it, together with the scope it was resolved from
expect(resolved).toEqual([{ name: 'Connection', fromRequest: true }]);
});
it('should run once for a singleton and on every resolve otherwise', () => {
let poolCount = 0;
let sessionCount = 0;
@register(singleton(), onResolve(() => poolCount++))
class ConnectionPool {}
@register(onResolve(() => sessionCount++))
class Session {}
const app = new Container({ tags: ['application'] })
.addRegistration(R.fromClass(ConnectionPool))
.addRegistration(R.fromClass(Session));
app.resolve('ConnectionPool');
app.resolve('ConnectionPool');
app.resolve('Session');
app.resolve('Session');
// A singleton caches the dependency, so hooks fire on the resolve that filled the cache
expect(poolCount).toBe(1);
expect(sessionCount).toBe(2);
});
});
Registration is provider factory which registers provider in container.
@register(bindTo('logger'))Registration.fromClass(Logger).bindTo('logger')Registration.fromClass(Logger)Registration.fromValue(Logger)Registration.fromFn(({ scope, ...options }) => scope.resolve(Logger, options))Use bindTo(key) to register a provider under a specific key. By default the key is the class name. Multiple registrations can share the same key.
A plain DependencyKey (string / symbol) or an InjectionToken that implements BindToken (SingleToken, SingleAliasToken, GroupAliasToken) can also be passed directly to @register(...) — it binds under the hood exactly like bindTo(...), so @register('ILogger') / @register(LoggerToken) and @register(bindTo('ILogger')) / @register(bindTo(LoggerToken)) are equivalent.
[!TIP] Prefer
SingleToken<T>over plain string literals as registration keys. Tokens are type-safe, rename-friendly, and prevent typos that only surface at runtime.
import {
bindTo,
Container,
DependencyMissingKeyError,
register,
Registration as R,
scope,
select as s,
SingleToken,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Registration Patterns
*
* Registrations define how dependencies are bound to the container.
* Common patterns:
* - Register by class (auto-generates key from class name)
* - Register by value (constants, configuration)
* - Register by factory function (dynamic creation)
* - Register with aliases (multiple keys for same service)
*
* This is the foundation for dependency injection - telling the container
* "when someone asks for X, give them Y".
*/
describe('Registration module', function () {
const createAppContainer = () => new Container({ tags: ['application'] });
it('should register class with scope and lifecycle', function () {
// Logger is registered at application scope as a singleton
@register(bindTo('ILogger'), scope((s) => s.hasTag('application')), singleton())
class Logger {}
const appContainer = createAppContainer().addRegistration(R.fromClass(Logger));
expect(appContainer.resolve('ILogger')).toBeInstanceOf(Logger);
});
it('should register configuration value', function () {
// Register application configuration as a value
const appContainer = createAppContainer().addRegistration(R.fromValue('production').bindToKey('Environment'));
expect(appContainer.resolve('Environment')).toBe('production');
});
it('should register factory function', function () {
// Factory functions are useful for dynamic creation
const appContainer = createAppContainer().addRegistration(
R.fromFn(() => `app-${Date.now()}`).bindToKey('RequestId'),
);
expect(appContainer.resolve('RequestId')).toContain('app-');
});
it('should raise an error if binding key is not provided', () => {
// Values and functions must have explicit keys (classes use class name by default)
expect(() => {
createAppContainer().addRegistration(R.fromValue('orphan-value'));
}).toThrowError(DependencyMissingKeyError);
});
it('should register dependency by class name when no key decorator is used', function () {
// Without @register(bindTo('key')), the class name becomes the key
class FileLogger {}
const appContainer = createAppContainer().addRegistration(R.fromClass(FileLogger));
expect(appContainer.resolve('FileLogger')).toBeInstanceOf(FileLogger);
});
it('should register directly from a token passed to @register, without wrapping it in bindTo()', function () {
// Passing an InjectionToken straight to @register binds to it under the hood
const LoggerToken = new SingleToken<FileLogger>('ILogger');
@register(LoggerToken, singleton())
class FileLogger {}
const appContainer = createAppContainer().addRegistration(R.fromClass(FileLogger));
expect(LoggerToken.resolve(appContainer)).toBeInstanceOf(FileLogger);
});
it('should register directly from a plain key passed to @register, without wrapping it in bindTo()', function () {
// A plain DependencyKey straight in @register binds to it under the hood
@register('PlainLogger')
class Logger {}
const appContainer = createAppContainer().addRegistration(R.fromClass(Logger));
expect(appContainer.resolve('PlainLogger')).toBeInstanceOf(Logger);
});
it('should register with multiple keys using aliases', function () {
// Same service accessible via direct key and alias
@register(bindTo('ILogger'), bindTo(s.alias('Logger')), singleton())
class Logger {}
const appContainer = createAppContainer().addRegistration(R.fromClass(Logger));
// Accessible via alias (for group resolution)
expect(appContainer.resolveByAlias('Logger')[0]).toBeInstanceOf(Logger);
// Accessible via direct key
expect(appContainer.resolve('ILogger')).toBeInstanceOf(Logger);
});
});
Sometimes you need to register provider only in scope which matches to certain condition and their sub scopes. Especially if you want to register dependency as singleton for some tags, for example root. This uses ScopeMatchRule to determine which scopes should have the provider.
@register(scope((container) => container.hasTag('root')) - register provider only in root scopeRegistration.fromClass(Logger).when((container) => container.hasTag('root'))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);
});
});
Picking an implementation by deployment environment (prod / dev / test)
needs no dedicated API - it is the same scope() mechanism as above: tag the
container with its env and scope each implementation to the env it belongs to.
Multiple registrations share the same key; only the one whose scope rule
matches the container's tag registers there.
import { bindTo, Container, register, Registration as R, scope, SingleToken } from 'ts-ioc-container';
/**
* Environment-based Registration
*
* An app often needs a different implementation per deployment environment -
* a real payment gateway in production, a stub in tests, a verbose one in dev.
* This is a plain application of `scope()`: tag the container with its env and
* scope each implementation to the env tag it belongs to. No dedicated API is
* needed - the same `ScopeMatchRule` mechanism used for `request` / `transaction`
* scopes applies here too.
*/
type Env = 'prod' | 'dev' | 'test';
interface IPaymentGateway {
charge(amount: number): string;
}
const PaymentGatewayToken = new SingleToken<IPaymentGateway>('IPaymentGateway');
@register(bindTo(PaymentGatewayToken), scope((c) => c.hasTag('prod')))
class StripeGateway implements IPaymentGateway {
charge(amount: number): string {
return `charged $${amount} via Stripe`;
}
}
@register(bindTo(PaymentGatewayToken), scope((c) => c.hasTag('dev')))
class LoggingGateway implements IPaymentGateway {
charge(amount: number): string {
return `[dev] would charge $${amount}`;
}
}
@register(bindTo(PaymentGatewayToken), scope((c) => c.hasTag('test')))
class FakeGateway implements IPaymentGateway {
charge(amount: number): string {
return `faked charge of $${amount}`;
}
}
const createContainerForEnv = (env: Env) =>
new Container({ tags: [env] })
.addRegistration(R.fromClass(StripeGateway))
.addRegistration(R.fromClass(LoggingGateway))
.addRegistration(R.fromClass(FakeGateway));
describe('Environment-based registration', function () {
it('should resolve the implementation whose scope tag matches the container env', function () {
expect(PaymentGatewayToken.resolve(createContainerForEnv('prod'))).toBeInstanceOf(StripeGateway);
expect(PaymentGatewayToken.resolve(createContainerForEnv('dev'))).toBeInstanceOf(LoggingGateway);
expect(PaymentGatewayToken.resolve(createContainerForEnv('test'))).toBeInstanceOf(FakeGateway);
});
it('should behave like the environment it was resolved for', function () {
const gateway = PaymentGatewayToken.resolve(createContainerForEnv('test'));
expect(gateway.charge(10)).toBe('faked charge of $10');
});
});
A decorator stack repeated on every class of a layer is worth a name.
createComposeClassDecorator(...decorators) gives it one: it returns a single
ClassDecorator which applies the stack bottom-up, exactly as stacking would,
so @createComposeClassDecorator(a, b) behaves like @a @b. A decorator which
returns a replacement class hands it to the next one, the way the runtime threads
a stack.
createComposeMethodDecorator and createComposeParameterDecorator do the same
for methods and constructor parameters. The method form threads the property
descriptor, so wrapping decorators (@once, @throttle, ...) compose too.
import {
addClassMeta,
Container,
createComposeClassDecorator,
createComposeParameterDecorator,
getClassMeta,
inject,
by,
addParamLabel,
getParamLabels,
register,
Registration as R,
scope,
singleton,
SingleToken,
} from 'ts-ioc-container';
/**
* A decorator stack repeated on every class of a layer is worth a name.
* `createComposeClassDecorator` (and its `createComposeMethodDecorator` /
* `createComposeParameterDecorator` siblings) turns one into a single decorator,
* applied bottom-up exactly as stacking would.
*/
describe('composing decorators', () => {
const INJECTION_TOKEN = 'injection-token';
// Every repository binds to its own token, is an application-scoped singleton,
// and remembers the token it was registered under.
const repository = <T>(token: SingleToken<T>) =>
createComposeClassDecorator(
register(
token,
scope((s) => s.hasTag('application')),
singleton(),
),
addClassMeta(INJECTION_TOKEN, () => token),
);
it('should apply the whole stack the composed decorator stands for', () => {
const UserRepositoryToken = new SingleToken<UserRepository>('IUserRepository');
@repository(UserRepositoryToken)
class UserRepository {
findById(id: string) {
return { id };
}
}
const app = new Container({ tags: ['application'] }).addRegistration(R.fromClass(UserRepository));
expect(UserRepositoryToken.resolve(app)).toBeInstanceOf(UserRepository);
// singleton() applied, so the same instance comes back
expect(UserRepositoryToken.resolve(app)).toBe(UserRepositoryToken.resolve(app));
// and the class still carries the metadata written beside the registration
expect(getClassMeta(UserRepository, INJECTION_TOKEN)).toBe(UserRepositoryToken);
});
it('should compose parameter decorators the same way', () => {
const ConfigToken = new SingleToken<{ apiUrl: string }>('IConfig');
// @inject plus a label describing where the value came from
const fromConfig = createComposeParameterDecorator(inject(by(ConfigToken)), addParamLabel('source', 'config'));
class ApiClient {
constructor(@fromConfig public config: { apiUrl: string }) {}
}
const app = new Container({ tags: ['application'] }).addRegistration(
R.fromValue({ apiUrl: 'https://api.example.com' }).bindTo(ConfigToken),
);
expect(app.resolve(ApiClient).config.apiUrl).toBe('https://api.example.com');
expect(getParamLabels(ApiClient, 0).get('source')).toBe('config');
});
});
Feature tokens let a feature flag choose which implementation is injected, so consumers depend on one type and never see the flag. The implementations decide where they belong - each one's own token declares its role:
token.variantOf(Feature, 'blue') - used while the flag serves the variant blue (getVariant)token.primaryVariantOf(Feature) - used while the flag is on and no named variant implementation matchestoken.fallbackOf(Feature) - used otherwise: the flag is off, serves nothing implemented, or its client throwsEvery flag has a fallback: resolving a feature without one registered is a
DependencyNotFoundError, whatever the flag serves. MultiVariantFeatureToken<T, V>
is the general case; ToggleFeatureToken<T> is its on/off case - no named
variants, only a primary one (isEnabled). Each implementation stays
injectable through its own token.
The flag client comes from IFeatureFlagsToken and the context it evaluates
for from IFeatureContextToken, both resolved from the resolving scope on every
resolution - register the context in your request scope. A missing client or
context is a DependencyNotFoundError, never a silent fallback.
IFeatureFlags is shaped after Unleash (isEnabled(name, context),
getVariant(name, context)), so an Unleash client is registered as is:
R.fromValue(unleash).bindTo(IFeatureFlagsToken). Resolution is synchronous;
wrap an asynchronous client (e.g. the OpenFeature server SDK) in an adapter
that evaluates ahead of time. Feature.lazy() defers the evaluation to first use.
import 'reflect-metadata';
import {
by,
Container,
type FeatureContext,
type FeatureVariant,
type IFeatureFlags,
IFeatureContextToken,
IFeatureFlagsToken,
inject,
MultiVariantFeatureToken,
register,
Registration as R,
SingleToken,
ToggleFeatureToken,
} from 'ts-ioc-container';
/**
* Checkout Domain - Feature Flags
*
* A flag chooses which implementation is injected; the consumer depends on one
* type and never sees the flag. The implementations decide where they belong:
* each one's own token declares it
*
* - `.variantOf(feature, name)` - used while the flag serves the variant `name`;
* - `.primaryVariantOf(feature)` - used while the flag is on and no named
* variant implementation matches (the "on" side of a toggle);
* - `.fallbackOf(feature)` - used otherwise. Every flag has one.
*
* `ToggleFeatureToken` is the on/off case of `MultiVariantFeatureToken`: no
* named variants, only a primary one.
*
* The flag client is any object with `isEnabled(name, context)` and
* `getVariant(name, context)` - an Unleash client works as is:
* container.addRegistration(R.fromValue(unleash).bindTo(IFeatureFlagsToken));
*/
// Toggle: new checkout on / off
interface IPaymentGateway {
pay(amount: number): string;
}
const PaymentGatewayToken = new ToggleFeatureToken<IPaymentGateway>('new-checkout');
// Each implementation's own token declares where it belongs
@register(new SingleToken<IPaymentGateway>('LegacyGateway').fallbackOf(PaymentGatewayToken))
class LegacyGateway implements IPaymentGateway {
pay(amount: number) {
return `legacy charged ${amount}`;
}
}
@register(new SingleToken<IPaymentGateway>('StripeGateway').primaryVariantOf(PaymentGatewayToken))
class StripeGateway implements IPaymentGateway {
pay(amount: number) {
return `stripe charged ${amount}`;
}
}
// Multi-variant: A/B/n experiment on the checkout button
interface ICheckoutButton {
render(): string;
}
const CheckoutButtonToken = new MultiVariantFeatureToken<ICheckoutButton, 'blue' | 'green'>('checkout-button');
@register(new SingleToken<ICheckoutButton>('GreyButton').fallbackOf(CheckoutButtonToken))
class GreyButton implements ICheckoutButton {
render() {
return 'grey button';
}
}
@register(new SingleToken<ICheckoutButton>('BlueButton').variantOf(CheckoutButtonToken, 'blue'))
class BlueButton implements ICheckoutButton {
render() {
return 'blue button';
}
}
@register(new SingleToken<ICheckoutButton>('GreenButton').variantOf(CheckoutButtonToken, 'green'))
class GreenButton implements ICheckoutButton {
render() {
return 'green button';
}
}
class CheckoutPage {
constructor(
@inject(by(PaymentGatewayToken)) private readonly gateway: IPaymentGateway,
@inject(by(CheckoutButtonToken)) private readonly button: ICheckoutButton,
) {}
render(amount: number) {
return `${this.button.render()}, ${this.gateway.pay(amount)}`;
}
}
// Stands in for an Unleash client
class InMemoryFlags implements IFeatureFlags {
isEnabled(name: string, context?: FeatureContext) {
return name === 'new-checkout' && context?.userId === 'beta-tester';
}
getVariant(name: string, context?: FeatureContext): FeatureVariant {
const bucket = { 'beta-tester': 'green', 'user-1': 'blue' }[context?.userId ?? ''];
return name === 'checkout-button' && bucket
? { name: bucket, enabled: true }
: { name: 'disabled', enabled: false };
}
}
describe('Feature flags', () => {
const app = new Container({ tags: ['application'] })
.addRegistration(R.fromValue(new InMemoryFlags()).bindTo(IFeatureFlagsToken))
.addRegistration(R.fromClass(LegacyGateway))
.addRegistration(R.fromClass(GreyButton))
.addRegistration(R.fromClass(StripeGateway))
.addRegistration(R.fromClass(BlueButton))
.addRegistration(R.fromClass(GreenButton));
// The context is per request: each request evaluates the flags for its own user
const handleRequest = (userId: string) => {
const request = app.createScope({ tags: ['request'] });
request.addRegistration(R.fromValue({ userId }).bindTo(IFeatureContextToken));
return request.resolve(CheckoutPage).render(42);
};
it('serves each user the implementations their flags select', () => {
expect(handleRequest('beta-tester')).toBe('green button, stripe charged 42');
expect(handleRequest('user-1')).toBe('blue button, legacy charged 42');
});
it('serves the fallbacks to everyone else', () => {
expect(handleRequest('regular-user')).toBe('grey button, legacy charged 42');
});
});
Sometimes you want to encapsulate registration logic in separate module. This is what IContainerModule is for.
import 'reflect-metadata';
import { bindTo, Container, type IContainer, type IContainerModule, register, Registration as R } from 'ts-ioc-container';
/**
* User Management Domain - Container Modules
*
* Modules organize related registrations and allow swapping implementations
* based on environment (development, testing, production).
*
* Common module patterns:
* - ProductionModule: Real database, external APIs, email service
* - DevelopmentModule: In-memory database, mock APIs, console logging
* - TestingModule: Mocks with assertion capabilities
*
* This enables:
* - Easy environment switching
* - Isolated testing without external dependencies
* - Feature flags via module composition
*/
// Auth service interface - same API for all environments
interface IAuthService {
authenticate(email: string, password: string): boolean;
getServiceType(): string;
}
// Production: Real authentication with database lookup
@register(bindTo('IAuthService'))
class ProductionAuthService implements IAuthService {
authenticate(email: string, password: string): boolean {
// In production, this would query the database
return email === '[email protected]' && password === 'secure_password';
}
getServiceType(): string {
return 'production';
}
}
// Development: Accepts any credentials for easy testing
@register(bindTo('IAuthService'))
class DevelopmentAuthService implements IAuthService {
authenticate(_email: string, _password: string): boolean {
// Always succeed in development for easier testing
return true;
}
getServiceType(): string {
return 'development';
}
}
// Production module - real services with security
class ProductionModule implements IContainerModule {
applyTo(container: IContainer): void {
container.addRegistration(R.fromClass(ProductionAuthService));
// In a real app, also register:
// - Real database connection
// - External email service
// - Payment gateway
}
}
// Development module - mocks and conveniences
class DevelopmentModule implements IContainerModule {
applyTo(container: IContainer): void {
container.addRegistration(R.fromClass(DevelopmentAuthService));
// In a real app, also register:
// - In-memory database
// - Console email logger
// - Mock payment gateway
}
}
describe('Container Modules', function () {
function createContainer(isProduction: boolean) {
const module = isProduction ? new ProductionModule() : new DevelopmentModule();
return new Container().useModule(module);
}
it('should use production auth with strict validation', function () {
const container = createContainer(true);
const auth = container.resolve<IAuthService>('IAuthService');
expect(auth.getServiceType()).toBe('production');
expect(auth.authenticate('[email protected]', 'secure_password')).toBe(true);
expect(auth.authenticate('[email protected]', 'wrong_password')).toBe(false);
});
it('should use development auth with permissive validation', function () {
const container = createContainer(false);
const auth = container.resolve<IAuthService>('IAuthService');
expect(auth.getServiceType()).toBe('development');
// Development mode accepts any credentials
expect(auth.authenticate('[email protected]', 'any_password')).toBe(true);
});
it('should allow composing multiple modules', function () {
// Modules can be composed for feature flags or A/B testing
class FeatureFlagModule implements IContainerModule {
constructor(private enableNewFeature: boolean) {}
applyTo(container: IContainer): void {
if (this.enableNewFeature) {
// Register new feature implementations
}
}
}
const container = new Container().useModule(new ProductionModule()).useModule(new FeatureFlagModule(true));
// Base services from ProductionModule
expect(container.resolve<IAuthService>('IAuthService').getServiceType()).toBe('production');
});
});
Sometimes you need to invoke methods after construct or dispose of class. This is what hooks are for.
@hook(key, hook) is the only hook decorator the library ships, and the key is
yours: there is no @onConstruct or @onScopeDisposed in the package, because
each is one line of your own code
(ADR 0017).
// yours, named in your vocabulary
const onScopeDisposed = (fn: HookType) => hook('onScopeDisposed', fn);
A decorated member takes one hook. Several hooks are combined at the declaration site, by the combinator that says how they relate:
class OrderService {
@hook('actions', sequential(authorize, validate, persist))
submit() {}
@onScopeDisposed(parallel(flushMetrics, closeSocket))
destroy() {}
}
sequential(...hooks) runs them in declaration order, awaiting each one that
goes async before the next.parallel(...hooks) starts them all at once and settles when every one has.oncePerInstance(hook) runs its hook a single time per instance, however
often the event fires.They compose, because each returns an ordinary HookFn:
oncePerInstance(sequential(connect, warmUp)), or a parallel(...) nested
inside a sequential(...). A hook class (HookType) may be passed anywhere a
hook function can. Writing your own combinator needs nothing from the library
beyond toHookFn.
A member carries exactly one hook per key, so decorating the same member twice under one key replaces the earlier hook rather than adding to it — decorators are applied bottom-up, so the topmost one is the one that stays.
Every hook may be sync or async — the one decorator takes both, so there is no separate async form to reach for. Running them is not the library's job at all, and neither is deciding when to collect them (ADR 0016, ADR 0018). The library answers one question — which hooks does this object declare, and against what context do they run — and the container's events are where you ask it.
A HookCollector is keyed to one of your hook keys and answers a single
question: getActions(target, { scope }) returns one HookAction per
decorated member — the hook resolved to a function, bound to the
IHookContext it runs against (which carries the member's own methodName). It performs nothing, and how the
hooks within one member relate was already settled by the combinator at the
declaration site (ADR 0015). It reads a
class's metadata once and reuses it, so collecting on a hot event is cheap.
A runner performs them. Order, awaiting and failure handling are decided
there and nowhere else; toTask turns an action into the Task that
runInOrder and runAtOnce take. The library exports no type for it — it
never calls a runner, nor is it handed one — so name the shape yourself:
// yours, like the hook keys
type HookRunner = (actions: HookAction[], context: ExecutionContext) => void;
// members one after another, never awaited: sync hooks finish before this returns
const immediate: HookRunner = (actions) => {
for (const { hook, context } of actions) {
hook(context);
}
};
// members one after another, awaiting any that goes async; both kinds of failure reported
const inOrder =
(onError: (scope: IContainer) => (error: unknown) => void): HookRunner =>
(actions, { scope }) => {
try {
runInOrder(actions.map(toTask))?.catch(onError(scope));
} catch (ex) {
onError(scope)(ex);
}
};
// every member started at once
const atOnce: HookRunner = (actions) => {
runAtOnce(actions.map(toTask));
};
Then wire collecting to the event you want it on. There is no
OnConstructModule in the package: an IContainerModule is one method, and
these are the whole of what the modules this library used to ship contained.
const onConstructHooks = new HookCollector({ key: 'onConstruct' });
const onScopeDisposedHooks = new HookCollector({ key: 'onScopeDisposed' });
// construction is the injector's event, and one injector backs the whole scope tree
const constructModule = (run: HookRunner): IContainerModule => ({
applyTo: (container) =>
container.getInjector().onConstructed((instance, scope) => {
run(onConstructHooks.getActions(instance, { scope }), { scope });
}),
});
// disposal is a scope event; collecting every instance into one list lets the
// runner order the instances as well as the members
const disposeModule = (run: HookRunner): IContainerModule => ({
applyTo: (container) =>
container.scopeDisposed.subscribe((scope) => {
run(
scope.getInstances().flatMap((instance) => onScopeDisposedHooks.getActions(instance, { scope })),
{ scope },
);
}),
});
const container = new Container().useModule(constructModule(inOrder(report))).useModule(disposeModule(atOnce));
For resolve hooks, reach the providers through registered.subscribe(...) and
provider.onResolved(...), or pipe a single registration with
onResolve. Narrowing is the collector's:
new HookCollector({ key: 'onConstruct', predicate }).
Resolution and disposal stay synchronous unless the runner makes them
otherwise: runInOrder and runAtOnce stay synchronous until a hook returns a
promise, so sync hooks finish before resolve (or dispose) returns and async
ones are started there and settle afterwards. Instances that must expose
readiness should publish it themselves, for example by storing the pending
promise on the instance.
Failures belong to the runner too — nothing in the library catches what a hook
throws or rejects with. A runner which neither guards nor awaits lets a sync
throw propagate out of resolve / dispose and drops an async rejection.
A custom key is collected and run the same way, with no module in between:
const workflow = new HookCollector({ key: 'workflow' });
container.getInjector().onConstructed((instance, scope) => {
runInOrder(workflow.getActions(instance, { scope }).map(toTask));
});
predicate, createExecutionContext and mapExecutionContext can be set on
the collector or overridden per getActions call.
The decorators above declare hook metadata on a class. The imperative side — the callbacks the container machinery runs — is registered on whichever abstraction raises the event, one hook type per domain:
| Domain | Registered on | Type | Where |
|---|---|---|---|
| Scope | IContainer | ScopeHook | scopeCreated.subscribe(...), scopeDisposed.subscribe(...) |
| Scope | IContainer | RegisteredHook | registered.subscribe(...) |
| Injector | IInjector | InjectorHook | onConstructed(...) |
| Provider | IProvider | ProviderHook | onResolved(...), or the onResolve pipe |
A container passes its injector to every scope it creates, so one injector
backs the whole scope tree, and container.getInjector() hands it out: an
onConstructed hook registered through it covers every scope, whenever it was
added. Scope hooks, by contrast, are copied into a child at createScope time,
so a child inherits what its parent held then and later additions to either
stay local.
const container = new Container({ tags: ['application'] });
// Scope events are typed events on the container itself
const stop = container.scopeCreated.subscribe((scope) => audit.scopeOpened(scope));
container.scopeDisposed.subscribe((scope) => audit.scopeClosed(scope));
container.registered.subscribe((provider, key) => audit.registered(key));
stop(); // detached; `container.scopeCreated.unsubscribe(fn)` does the same by reference
// Construction is the injector's event, not a scope's
container.getInjector().onConstructed((instance, scope) => metrics.built(instance, scope));
The scope events are scopeCreated, scopeDisposed (ITypedEvent<[IContainer]>)
and registered (ITypedEvent<[IProvider, DependencyKey, IContainer]>);
subscribe returns the unsubscribe function. The exposed events carry no
emit — only the container raises its own events.
TypedEvent itself is exported for your own events: subscribe / unsubscribe
/ emit / dispose, with ITypedEvent as the subscriber-only view to hand out.
These four events are the whole surface hooks are wired to; the library ships no module over them (ADR 0018).
Prefer collecting on resolve — through
registered/onResolved, or theonResolvepipe — over collecting on construction. Construction is the injector's event, so it fires only for dependencies the injector builds: afromValueconstant or a factory registration never triggers it. It also observes the instance before the provider'sdecorate(...)chain wraps it, so a hook sees the bare instance rather than what the caller receives. A resolve hook runs on every dependency leaving a provider, after the whole decorate chain — the same ordering, awaiting and error handling, on what the caller actually gets. Reach for construct hooks only when you mean "this class was just constructed" specifically.
import 'reflect-metadata';
import {
Container,
type HookFn,
type IContainer,
hook,
HookCollector,
type HookType,
type IContainerModule,
inject,
Registration as R,
runInOrder,
toTask,
type ExecutionContext,
type HookAction,
by,
} from 'ts-ioc-container';
const execute: HookFn = (ctx) => {
ctx.invokeMethod({ args: ctx.resolveArgs() });
};
const executeAsync: HookFn = async (ctx) => {
await ctx.invokeMethod({ args: ctx.resolveArgs() });
};
// The library ships no construct decorator: the key, the decorator which writes
// it and the collector which reads it are all ours.
const onConstruct = (fn: HookType) => hook('onConstruct', fn);
const onConstructHooks = new HookCollector({ key: 'onConstruct' });
// Running the collected hooks is ours too, and so is naming the shape that does
// it: the library neither calls a runner nor is handed one.
type HookRunner = (actions: HookAction[], context: ExecutionContext) => void;
// This runner keeps the
// actions in declaration order, stays synchronous until one returns a promise,
// and reports a throw and a rejection alike.
const run =
(onError: (scope: IContainer) => (ex: unknown) => void = () => () => {}): HookRunner =>
(actions, { scope }) => {
try {
runInOrder(actions.map(toTask))?.catch(onError(scope));
} catch (ex) {
onError(scope)(ex);
}
};
// Construction is the injector's event, and hanging the collection off it is
// ours: the library ships no module for that, and a module is just an `applyTo`.
const onConstructModule = (run: HookRunner): IContainerModule => ({
applyTo: (container) =>
container.getInjector().onConstructed((instance, scope) => {
run(onConstructHooks.getActions(instance, { scope }), { scope });
}),
});
describe('onConstruct', function () {
it('should run initialization method after dependencies are resolved', function () {
class DatabaseConnection {
isConnected = false;
connectionString = '';
@onConstruct(execute)
connect(@inject(by('ConnectionString')) connectionString: string) {
this.connectionString = connectionString;
this.isConnected = true;
}
}
const container = new Container()
.useModule(onConstructModule(run()))
.addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));
const db = container.resolve(DatabaseConnection);
expect(db.isConnected).toBe(true);
expect(db.connectionString).toBe('postgres://localhost:5432');
});
it('should forward hook exceptions to the runner’s error handler with the scope', function () {
const failure = new Error('boom');
class BrokenService {
@onConstruct(() => {
throw failure;
})
init() {}
}
let captured: { ex: unknown; scope: IContainer } | undefined;
const container = new Container().useModule(
onConstructModule(
run((scope) => (ex) => {
captured = { ex, scope };
}),
),
);
expect(() => container.resolve(BrokenService)).not.toThrow();
expect(captured?.ex).toBe(failure);
expect(captured?.scope).toBe(container);
});
it('should expose the resolving scope to the runner’s error handler', function () {
class BrokenService {
@onConstruct(() => {
throw new Error('boom');
})
init() {}
}
let scope: IContainer | undefined;
const container = new Container().useModule(
onConstructModule(
run((s) => () => {
scope = s;
}),
),
);
const child = container.createScope();
child.resolve(BrokenService);
expect(scope).toBe(child);
});
it('should run an async initialization method after the instance is created', async function () {
class DatabaseConnection {
isConnected = false;
connectionString = '';
readonly ready: Promise<void>;
private markReady!: () => void;
constructor() {
this.ready = new Promise((resolve) => {
this.markReady = resolve;
});
}
@onConstruct(executeAsync)
async connect(@inject(by('ConnectionString')) connectionString: string) {
await Promise.resolve();
this.connectionString = connectionString;
this.isConnected = true;
this.markReady();
}
}
// The runner awaits the hooks; resolution itself still does not wait for them.
const container = new Container()
.useModule(onConstructModule(run()))
.addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));
const db = container.resolve(DatabaseConnection);
// resolution does not wait for async hooks
expect(db.isConnected).toBe(false);
await db.ready;
expect(db.isConnected).toBe(true);
expect(db.connectionString).toBe('postgres://localhost:5432');
});
it('should forward rejected hooks to the runner’s error handler with the scope', async function () {
const failure = new Error('boom');
class BrokenService {
@onConstruct(() => Promise.reject(failure))
init() {}
}
let captured: { ex: unknown; scope: IContainer } | undefined;
const container = new Container().useModule(
onConstructModule(
run((scope) => (ex) => {
captured = { ex, scope };
}),
),
);
const child = container.createScope();
child.resolve(BrokenService);
await vi.waitFor(() => expect(captured).toBeDefined());
expect(captured?.ex).toBe(failure);
expect(captured?.scope).toBe(child);
});
});
import 'reflect-metadata';
import {
bindTo,
Container,
type HookFn,
hook,
HookCollector,
type HookType,
inject,
register,
Registration as R,
singleton,
type ExecutionContext,
type HookAction,
type IContainerModule,
by,
} from 'ts-ioc-container';
const execute: HookFn = (ctx) => {
ctx.invokeMethod({ args: ctx.resolveArgs() });
};
// The library ships no dispose decorator: the key, the decorator which writes it
// and the collector which reads it are all ours.
const onScopeDisposed = (fn: HookType) => hook('onScopeDisposed', fn);
const onScopeDisposedHooks = new HookCollector({ key: 'onScopeDisposed' });
// Naming the shape which performs collected actions is ours: the library
// neither calls a runner nor is handed one.
type HookRunner = (actions: HookAction[], context: ExecutionContext) => void;
// This runner performs the collected hooks in order and never awaits.
const run: HookRunner = (actions) => {
for (const { hook, context } of actions) {
hook(context);
}
};
// Disposal is a scope event, and hanging the collection off it is ours: the
// library ships no module for that, and a module is just an `applyTo`. Every
// instance of the scope is collected into one list, so the runner orders the
// instances as well as the members.
const onScopeDisposedModule = (run: HookRunner): IContainerModule => ({
applyTo: (container) =>
container.scopeDisposed.subscribe((scope) => {
run(
scope.getInstances().flatMap((instance) => onScopeDisposedHooks.getActions(instance, { scope })),
{ scope },
);
}),
});
@register(bindTo('logsRepo'), singleton())
class LogsRepo {
savedLogs: string[] = [];
saveLogs(messages: string[]) {
this.savedLogs.push(...messages);
}
}
@register(bindTo('logger'))
class Logger {
private messages: string[] = [];
constructor(@inject(by('logsRepo')) private logsRepo: LogsRepo) {}
log(message: string): void {
this.messages.push(message);
}
@onScopeDisposed(execute)
save() {
this.logsRepo.saveLogs(this.messages);
}
}
describe('onScopeDisposed', function () {
it('should invoke hooks on all instances when container is disposed', function () {
const container = new Container()
.useModule(onScopeDisposedModule(run))
.addRegistration(R.fromClass(Logger))
.addRegistration(R.fromClass(LogsRepo));
const logger = container.resolve<Logger>('logger');
logger.log('Hello');
const logsRepo = container.resolve<LogsRepo>('logsRepo');
container.dispose();
expect(logsRepo.savedLogs).toEqual(['Hello']);
});
});
import 'reflect-metadata';
import { Container, hook, HookCollector, injectProp, Registration, sequential, toTask, by } from 'ts-ioc-container';
/**
* UI Components - Property Injection
*
* Property injection is useful when you don't control the class instantiation
* (like in some UI frameworks, Web Components, or legacy systems) or when
* you want to avoid massive constructors in base classes.
*
* This example demonstrates a ViewModel that gets dependencies injected
* AFTER construction via an initialization hook.
*/
describe('inject property', () => {
it('should inject property', () => {
// Collector for the 'onInit' lifecycle hook
const onInit = new HookCollector({ key: 'onInit' });
class UserViewModel {
// Inject 'GreetingService' into 'greeting' property during 'onInit'
@hook('onInit', injectProp(by('GreetingService')))
greetingService!: string;
display(): string {
return `${this.greetingService} User`;
}
}
const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));
// 1. Create instance (dependencies not yet injected)
const viewModel = container.resolve(UserViewModel);
// 2. Collect the lifecycle hooks and run them to inject properties
onInit
.getActions(viewModel, { scope: container })
.map(toTask)
.forEach((task) => task());
expect(viewModel.greetingService).toBe('Hello');
expect(viewModel.display()).toBe('Hello User');
});
it('should read the applied instance property via getProperty', () => {
const onInit = new HookCollector({ key: 'onInit' });
let injectedValue: unknown;
class UserViewModel {
@hook(
'onInit',
sequential(injectProp(by('GreetingService')), (context) => {
injectedValue = context.getProperty();
}),
)
greetingService!: string;
}
const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));
const viewModel = container.resolve(UserViewModel);
onInit
.getActions(viewModel, { scope: container })
.map(toTask)
.forEach((task) => task());
expect(injectedValue).toBe('Hello');
});
});
The product-facing error contract is described in
specs/epics/errors-and-boundaries.md and executed by
__tests__/specs/errors-and-boundaries.spec.ts.