UNPKG

ts-ioc-container

Version:

Fast, lightweight TypeScript dependency injection container with a clean API, scoped lifecycles, decorators, tokens, hooks, lazy injection, customizable providers, and no global container objects.

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