Container

Goal: Manage the lifecycle of dependencies, resolve them when requested, and maintain scoped instances.

The Container is the central component of the IoC system. Understanding containers is essential for effective dependency injection.

Scopes allow you to create isolated dependency contexts. In web applications, you typically have:

Each scope can have tags that control which providers are available, preventing accidental access to request-specific data from singletons.

Request Scope Pattern (Express.js)

The most common pattern is creating a request-scoped container for each HTTP request:

TypeScript Express.js middleware
// Middleware: Create request scope for each request
app.use((req, res, next) => {
// Create isolated scope for this request
req.container = appContainer.createScope({ tags: ['request'] });

// Clean up when response finishes
res.on('finish', () => req.container.dispose());
next();
});

// SessionService is isolated per request - no data leaks between users
const session = req.container.resolve<SessionService>('ISessionService');
session.setCurrentUser(authenticatedUserId);
// Middleware: Create request scope for each request
app.use((req, res, next) => {
// Create isolated scope for this request
req.container = appContainer.createScope({ tags: ['request'] });

// Clean up when response finishes
res.on('finish', () => req.container.dispose());
next();
});

// SessionService is isolated per request - no data leaks between users
const session = req.container.resolve<SessionService>('ISessionService');
session.setCurrentUser(authenticatedUserId);

Tagged Scopes

Tags allow you to control which providers are available in which scopes. Services can be restricted to specific scope types for security and architectural clarity.

TypeScript __tests__/readme/scopes.spec.ts
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);
  });
});
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);
  });
});

Scope Hierarchy

Containers can create child scopes, forming a tree structure. Each scope maintains its own provider registry and instance cache, but can fall back to parent scopes for resolution.

graph TD Root["Root Container
tags: 'root'"] Request1["Request Scope 1
tags: 'request', 'user:123'"] Request2["Request Scope 2
tags: 'request', 'user:456'"] Feature1["Feature Scope 1
tags: 'feature:admin'"] Feature2["Feature Scope 2
tags: 'feature:user'"] Root -->|createScope| Request1 Root -->|createScope| Request2 Request1 -->|createScope| Feature1 Request2 -->|createScope| Feature2 style Root fill:#0366d6,color:#fff style Request1 fill:#28a745,color:#fff style Request2 fill:#28a745,color:#fff style Feature1 fill:#ffc107,color:#000 style Feature2 fill:#ffc107,color:#000

Creating a Scope

When you create a new scope using createScope(), the container follows a specific process to set up the child scope. The new scope maintains a reference to its parent, allowing it to access parent registrations while maintaining its own isolated instance cache.

flowchart TD Create([Create new Container as Scope
with parent reference]) --> CopyHooks[Copy hooks
onConstruct, onDispose] CopyHooks --> GetRegs[Get all registrations
parent + current scope] GetRegs --> Apply[Apply registration to new scope] Apply --> AddScope[Add scope to
scopes list] AddScope --> Return([Return new scope]) classDef startEnd fill:#64748b,stroke:#475569,color:#fff classDef process fill:#3b82f6,stroke:#2563eb,color:#fff classDef action fill:#8b5cf6,stroke:#7c3aed,color:#fff class Create,Return startEnd class CopyHooks,GetRegs,AddScope process class Apply action

The scope creation process ensures that lifecycle hooks are properly inherited, all relevant registrations are available, and the new scope is properly registered in the parent’s scope list for management and disposal.

Scope Resolution Strategy

When resolving a dependency, the container follows this strategy:

  1. Check if provider exists in current scope
  2. Check if provider has access in current scope (scope access rule)
  3. If not found, recursively check parent scopes
  4. If found in parent, check access from current scope
  5. Throw DependencyNotFoundError if not found anywhere

The addTags() method allows you to dynamically add tags to a container after it’s been created. This is particularly useful for:

Important: Tags must be added before registrations are applied. Scope matching happens at registration time, so adding tags after registration won’t retroactively make providers available.

TypeScript __tests__/readme/addTags.spec.ts
import { bindTo, Container, register, Registration as R, scope } from 'ts-ioc-container';

describe('addTags', () => {
  it('should dynamically add tags to enable environment-based registration', () => {
    @register(bindTo('logger'), scope((s) => s.hasTag('development')))
    class ConsoleLogger {
      log(message: string) {
        console.log(`[DEV] ${message}`);
      }
    }

    @register(bindTo('logger'), scope((s) => s.hasTag('production')))
    class FileLogger {
      log(message: string) {
        console.log(`[PROD] ${message}`);
      }
    }

    // Create container and configure for environment
    const container = new Container();
    const environment = 'development';
    container.addTags(environment); // Add tag dynamically based on environment

    // Register services after tag is set
    container.addRegistration(R.fromClass(ConsoleLogger)).addRegistration(R.fromClass(FileLogger));

    // Resolve logger - gets ConsoleLogger because 'development' tag was added
    const logger = container.resolve<ConsoleLogger>('logger');
    expect(logger).toBeInstanceOf(ConsoleLogger);
  });

  it('should add multiple tags for feature-based configuration', () => {
    @register(bindTo('premiumFeature'), scope((s) => s.hasTag('premium')))
    class PremiumFeature {}

    @register(bindTo('betaFeature'), scope((s) => s.hasTag('beta')))
    class BetaFeature {}

    const container = new Container();

    // Add multiple tags at once
    container.addTags('premium', 'beta', 'experimental');

    // Verify all tags are present
    expect(container.hasTag('premium')).toBe(true);
    expect(container.hasTag('beta')).toBe(true);
    expect(container.hasTag('experimental')).toBe(true);

    // Register features after tags are added
    container.addRegistration(R.fromClass(PremiumFeature)).addRegistration(R.fromClass(BetaFeature));

    // Both features are available because container has both tags
    expect(container.resolve('premiumFeature')).toBeInstanceOf(PremiumFeature);
    expect(container.resolve('betaFeature')).toBeInstanceOf(BetaFeature);
  });

  it('should affect child scope creation', () => {
    @register(bindTo('service'), scope((s) => s.hasTag('api')))
    class ApiService {
      handleRequest() {
        return 'API response';
      }
    }

    const appContainer = new Container();

    // Add tag to parent
    appContainer.addTags('api');
    appContainer.addRegistration(R.fromClass(ApiService));

    // Create child scopes - they inherit parent's registrations
    const requestScope1 = appContainer.createScope({ tags: ['request'] });
    const requestScope2 = appContainer.createScope({ tags: ['request'] });

    // Both scopes can access the ApiService from parent
    expect(requestScope1.resolve<ApiService>('service').handleRequest()).toBe('API response');
    expect(requestScope2.resolve<ApiService>('service').handleRequest()).toBe('API response');
  });

  it('should enable incremental tag addition', () => {
    const container = new Container();

    // Start with basic tags
    container.addTags('application');
    expect(container.hasTag('application')).toBe(true);

    // Add more tags as needed
    container.addTags('monitoring', 'logging');
    expect(container.hasTag('monitoring')).toBe(true);
    expect(container.hasTag('logging')).toBe(true);

    // All tags are retained
    expect(container.hasTag('application')).toBe(true);
  });
});
import { bindTo, Container, register, Registration as R, scope } from 'ts-ioc-container';

describe('addTags', () => {
  it('should dynamically add tags to enable environment-based registration', () => {
    @register(bindTo('logger'), scope((s) => s.hasTag('development')))
    class ConsoleLogger {
      log(message: string) {
        console.log(`[DEV] ${message}`);
      }
    }

    @register(bindTo('logger'), scope((s) => s.hasTag('production')))
    class FileLogger {
      log(message: string) {
        console.log(`[PROD] ${message}`);
      }
    }

    // Create container and configure for environment
    const container = new Container();
    const environment = 'development';
    container.addTags(environment); // Add tag dynamically based on environment

    // Register services after tag is set
    container.addRegistration(R.fromClass(ConsoleLogger)).addRegistration(R.fromClass(FileLogger));

    // Resolve logger - gets ConsoleLogger because 'development' tag was added
    const logger = container.resolve<ConsoleLogger>('logger');
    expect(logger).toBeInstanceOf(ConsoleLogger);
  });

  it('should add multiple tags for feature-based configuration', () => {
    @register(bindTo('premiumFeature'), scope((s) => s.hasTag('premium')))
    class PremiumFeature {}

    @register(bindTo('betaFeature'), scope((s) => s.hasTag('beta')))
    class BetaFeature {}

    const container = new Container();

    // Add multiple tags at once
    container.addTags('premium', 'beta', 'experimental');

    // Verify all tags are present
    expect(container.hasTag('premium')).toBe(true);
    expect(container.hasTag('beta')).toBe(true);
    expect(container.hasTag('experimental')).toBe(true);

    // Register features after tags are added
    container.addRegistration(R.fromClass(PremiumFeature)).addRegistration(R.fromClass(BetaFeature));

    // Both features are available because container has both tags
    expect(container.resolve('premiumFeature')).toBeInstanceOf(PremiumFeature);
    expect(container.resolve('betaFeature')).toBeInstanceOf(BetaFeature);
  });

  it('should affect child scope creation', () => {
    @register(bindTo('service'), scope((s) => s.hasTag('api')))
    class ApiService {
      handleRequest() {
        return 'API response';
      }
    }

    const appContainer = new Container();

    // Add tag to parent
    appContainer.addTags('api');
    appContainer.addRegistration(R.fromClass(ApiService));

    // Create child scopes - they inherit parent's registrations
    const requestScope1 = appContainer.createScope({ tags: ['request'] });
    const requestScope2 = appContainer.createScope({ tags: ['request'] });

    // Both scopes can access the ApiService from parent
    expect(requestScope1.resolve<ApiService>('service').handleRequest()).toBe('API response');
    expect(requestScope2.resolve<ApiService>('service').handleRequest()).toBe('API response');
  });

  it('should enable incremental tag addition', () => {
    const container = new Container();

    // Start with basic tags
    container.addTags('application');
    expect(container.hasTag('application')).toBe(true);

    // Add more tags as needed
    container.addTags('monitoring', 'logging');
    expect(container.hasTag('monitoring')).toBe(true);
    expect(container.hasTag('logging')).toBe(true);

    // All tags are retained
    expect(container.hasTag('application')).toBe(true);
  });
});

Usage Pattern

// 1. Create container
const container = new Container();

// 2. Add tags based on configuration
container.addTags('production', 'api', 'v2');

// 3. Apply registrations (scope matching happens here)
container.addRegistration(R.fromClass(ProductionService));

Scope System Rule (Similar to JavaScript Scoping)

The container scope system works like JavaScript’s lexical scoping:

TypeScript
// JavaScript analogy
function parent() {
const parentVar = 'accessible from child';

function child() {
  const childVar = 'NOT accessible from parent';
  console.log(parentVar); // ✅ Works - child can access parent
}

console.log(childVar); // ❌ ReferenceError - parent cannot access child
}
// JavaScript analogy
function parent() {
const parentVar = 'accessible from child';

function child() {
  const childVar = 'NOT accessible from parent';
  console.log(parentVar); // ✅ Works - child can access parent
}

console.log(childVar); // ❌ ReferenceError - parent cannot access child
}

Important: A service’s resolution context is determined by where it is registered, not where it’s requested from. This creates an important limitation when parent-scoped services try to inject child-scoped dependencies.

flowchart TB subgraph Scenario1["❌ Parent → Child (FAILS)"] direction TB P1[Parent Scope
tag: 'parent'] C1[Child Scope
tag: 'child'] SA1["Service A
scope: parent only
needs Service B"] SB1["Service B
scope: child only"] P1 -.contains.-> SA1 C1 -.contains.-> SB1 P1 -->|parent of| C1 R1_1["1️⃣ Resolve Service A"] R1_2["2️⃣ A's context = Parent"] R1_3["3️⃣ A needs B"] R1_4["4️⃣ Search: Parent → EmptyContainer"] R1_5["❌ B not found
DependencyNotFoundError"] R1_1 --> R1_2 --> R1_3 --> R1_4 --> R1_5 style P1 fill:#3d2a1f,stroke:#ff6e40,stroke-width:2px,color:#fff style C1 fill:#1a2332,stroke:#4fc3f7,stroke-width:2px,color:#fff style SA1 fill:#4a2622,stroke:#ff5252,stroke-width:2px,color:#fff style SB1 fill:#1e3a5f,stroke:#42a5f5,stroke-width:2px,color:#fff style R1_5 fill:#5d1f1f,stroke:#ff5252,stroke-width:3px,color:#ffcdd2 end subgraph Scenario2["✅ Child → Parent (WORKS)"] direction TB P2[Parent Scope
tag: 'parent'] C2[Child Scope
tag: 'child'] SA2["Service A
scope: parent only"] SB2["Service B
scope: child only
needs Service A"] P2 -.contains.-> SA2 C2 -.contains.-> SB2 P2 -->|parent of| C2 R2_1["1️⃣ Resolve Service B"] R2_2["2️⃣ B's context = Child"] R2_3["3️⃣ B needs A"] R2_4["4️⃣ Search: Child → Parent"] R2_5["✅ A found in Parent
Success"] R2_1 --> R2_2 --> R2_3 --> R2_4 --> R2_5 style P2 fill:#3d2a1f,stroke:#ff6e40,stroke-width:2px,color:#fff style C2 fill:#1a2332,stroke:#4fc3f7,stroke-width:2px,color:#fff style SA2 fill:#4a2622,stroke:#ff5252,stroke-width:2px,color:#fff style SB2 fill:#1e3a5f,stroke:#42a5f5,stroke-width:2px,color:#fff style R2_5 fill:#1f3d1f,stroke:#66bb6a,stroke-width:3px,color:#a5d6a7 end style Scenario1 fill:#1a1a1a,stroke:#f44336,stroke-width:3px,color:#fff style Scenario2 fill:#1a1a1a,stroke:#4caf50,stroke-width:3px,color:#fff

Resolution Context Principle

When a service is registered with scope((c) => c.hasTag('parent')), it always resolves from the parent container—even if the service is requested through a child scope.

Parent → Child Dependency (❌ FAILS)

Scenario: Parent-scoped service A tries to inject child-scoped service B

TypeScript
// Service A - registered only for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA {
constructor(@inject('ServiceB') serviceB: ServiceB) {
  // ❌ Throws DependencyNotFoundError
}
}

// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB { }
// Service A - registered only for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA {
constructor(@inject('ServiceB') serviceB: ServiceB) {
  // ❌ Throws DependencyNotFoundError
}
}

// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB { }

Resolution flow:

  1. A is registered for parent scope only
  2. A’s resolution context = parent container
  3. A looks for B in parent scope → parent’s parent (EmptyContainer)
  4. B only exists in child scope (invisible to parent)
  5. Result: DependencyNotFoundError thrown

Even if A is requested from a child scope, A still resolves from the parent container internally.

Child → Parent Dependency (✅ WORKS)

Scenario: Child-scoped service B tries to inject parent-scoped service A

TypeScript
// Service A - registered for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA { }

// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB {
constructor(@inject('ServiceA') serviceA: ServiceA) {
  // ✅ Works - A is found via upward cascade
}
}
// Service A - registered for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA { }

// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB {
constructor(@inject('ServiceA') serviceA: ServiceA) {
  // ✅ Works - A is found via upward cascade
}
}

Resolution flow:

  1. B is registered for child scope
  2. B’s resolution context = child container
  3. B looks for A: child scope → parent scope
  4. A found in parent scope (accessible via cascade)
  5. Result: Success

Summary

Workarounds

If you need a parent-scoped service to access child-scoped dependencies, consider these options:

TypeScript
// Option 1: Register for both scopes
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent') || c.hasTag('child'))
)
class ServiceA { }

// Option 2: Use scopeAccess for visibility control instead
@register(
bindTo('ServiceA'),
// Available in all scopes
scopeAccess(({ invocationScope }) =>
  invocationScope.hasTag('parent') || invocationScope.hasTag('child')
)
)
class ServiceA { }
// Option 1: Register for both scopes
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent') || c.hasTag('child'))
)
class ServiceA { }

// Option 2: Use scopeAccess for visibility control instead
@register(
bindTo('ServiceA'),
// Available in all scopes
scopeAccess(({ invocationScope }) =>
  invocationScope.hasTag('parent') || invocationScope.hasTag('child')
)
)
class ServiceA { }

The following sequence diagram illustrates how a dependency is resolved from the container:

flowchart TD Start([Resolve dependency]) Start --> CheckConstructor{Is target
constructor?} CheckConstructor -->|Yes| Injector[Resolve constructor
from Injector] CheckConstructor -->|No| FindProvider[Find provider
by string or symbol] FindProvider --> Container{Provider exists
and has access
to current scope?} Container -->|Yes| Provider[Resolve dependency
from provider] Container -->|No| ResolveParent[Resolve dependency
from parent scope] style Start fill:#4CAF50,stroke:#2E7D32,stroke-width:3px,color:#fff style CheckConstructor fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Container fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Injector fill:#2196F3,stroke:#1565C0,stroke-width:2px,color:#fff style FindProvider fill:#9C27B0,stroke:#6A1B9A,stroke-width:2px,color:#fff style Provider fill:#00BCD4,stroke:#00838F,stroke-width:2px,color:#fff style ResolveParent fill:#FF5722,stroke:#BF360C,stroke-width:2px,color:#fff

The container tracks all instances it creates, allowing you to query and manage them. This is particularly useful for cleanup operations, debugging, or implementing custom lifecycle management.

Querying Instances

TypeScript __tests__/readme/instances.spec.ts
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);
  });
});
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);
  });
});

Filtering Instances

TypeScript __tests__/readme/filteringInstances.spec.ts
import { bindTo, Container, inject, register, Registration as R, select } from 'ts-ioc-container';

/**
 * User Management Domain - Filtering Instances by Type
 *
 * When you need specific types of instances from the container:
 * - Get all Logger instances for batch flushing
 * - Find all HealthCheckable services for status endpoint
 * - Collect all EventHandler instances for event dispatching
 *
 * The predicate function filters instances at resolution time,
 * ensuring you only get the instances you need.
 */

@register(bindTo('ILogger'))
class Logger {}

class Service {}

// Predicate to identify Logger instances
const isLogger = (instance: unknown) => instance instanceof Logger;

class App {
  // Only inject instances that pass the isLogger predicate
  constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}

describe('Filtering Instances', function () {
  it('should filter instances by type predicate', function () {
    const container = new Container({ tags: ['application'] })
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(Service).bindToKey('IService'));

    // Create multiple instances of different types
    const logger1 = container.resolve('ILogger');
    const logger2 = container.resolve('ILogger');
    container.resolve('IService'); // This won't be included

    const app = container.resolve(App);

    // Only Logger instances are injected, Service is filtered out
    expect(app.loggers).toHaveLength(2);
    expect(app.loggers[0]).toBe(logger1);
    expect(app.loggers[1]).toBe(logger2);
    expect(app.loggers.every((l) => l instanceof Logger)).toBe(true);
    expect(app.loggers.some((l) => l instanceof Service)).toBe(false);
  });

  it('should return empty array when no instances match predicate', function () {
    const container = new Container({ tags: ['application'] }).addRegistration(
      R.fromClass(Service).bindToKey('IService'),
    );

    container.resolve('IService');

    const app = container.resolve(App);

    // No Logger instances exist, so empty array is injected
    expect(app.loggers).toHaveLength(0);
  });
});
import { bindTo, Container, inject, register, Registration as R, select } from 'ts-ioc-container';

/**
 * User Management Domain - Filtering Instances by Type
 *
 * When you need specific types of instances from the container:
 * - Get all Logger instances for batch flushing
 * - Find all HealthCheckable services for status endpoint
 * - Collect all EventHandler instances for event dispatching
 *
 * The predicate function filters instances at resolution time,
 * ensuring you only get the instances you need.
 */

@register(bindTo('ILogger'))
class Logger {}

class Service {}

// Predicate to identify Logger instances
const isLogger = (instance: unknown) => instance instanceof Logger;

class App {
  // Only inject instances that pass the isLogger predicate
  constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}

describe('Filtering Instances', function () {
  it('should filter instances by type predicate', function () {
    const container = new Container({ tags: ['application'] })
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(Service).bindToKey('IService'));

    // Create multiple instances of different types
    const logger1 = container.resolve('ILogger');
    const logger2 = container.resolve('ILogger');
    container.resolve('IService'); // This won't be included

    const app = container.resolve(App);

    // Only Logger instances are injected, Service is filtered out
    expect(app.loggers).toHaveLength(2);
    expect(app.loggers[0]).toBe(logger1);
    expect(app.loggers[1]).toBe(logger2);
    expect(app.loggers.every((l) => l instanceof Logger)).toBe(true);
    expect(app.loggers.some((l) => l instanceof Service)).toBe(false);
  });

  it('should return empty array when no instances match predicate', function () {
    const container = new Container({ tags: ['application'] }).addRegistration(
      R.fromClass(Service).bindToKey('IService'),
    );

    container.resolve('IService');

    const app = container.resolve(App);

    // No Logger instances exist, so empty array is injected
    expect(app.loggers).toHaveLength(0);
  });
});

The hasRegistration() method allows you to check if a registration with a specific key exists in the container. This is useful for conditional registration logic, validation, and debugging.

Key Features

TypeScript __tests__/readme/hasRegistration.spec.ts
import { Container, Registration as R, bindTo, register, SingleToken } from 'ts-ioc-container';

/**
 * Container Registration Checking - hasRegistration
 *
 * The `hasRegistration` method allows you to check if a registration with a specific key
 * exists in the current container. This is useful for conditional registration logic,
 * validation, and debugging.
 *
 * Key points:
 * - Checks only the current container's registrations (not parent containers)
 * - Works with string keys, symbol keys, and token keys
 * - Returns false after container disposal
 * - Useful for conditional registration patterns
 */
describe('hasRegistration', function () {
  const createAppContainer = () => new Container({ tags: ['application'] });

  it('should return true when registration exists with string key', function () {
    const container = createAppContainer();
    container.addRegistration(R.fromValue('production').bindToKey('Environment'));

    expect(container.hasRegistration('Environment')).toBe(true);
  });

  it('should return false when registration does not exist', function () {
    const container = createAppContainer();

    expect(container.hasRegistration('NonExistentService')).toBe(false);
  });

  it('should work with symbol keys', function () {
    const container = createAppContainer();
    const serviceKey = Symbol('IService');
    container.addRegistration(R.fromValue({ name: 'Service' }).bindToKey(serviceKey));

    expect(container.hasRegistration(serviceKey)).toBe(true);
  });

  it('should work with token keys', function () {
    const container = createAppContainer();
    const loggerToken = new SingleToken<{ log: (msg: string) => void }>('ILogger');
    container.addRegistration(R.fromValue({ log: () => {} }).bindTo(loggerToken));

    expect(container.hasRegistration(loggerToken.token)).toBe(true);
  });

  it('should check current container and parent registrations', function () {
    // Parent container has a registration
    const parent = createAppContainer();
    parent.addRegistration(R.fromValue('parent-config').bindToKey('Config'));

    // Child scope does not have the registration
    const child = parent.createScope();
    child.addRegistration(R.fromValue('child-service').bindToKey('Service'));

    // Child should see parent's registration (checks parent as well)
    expect(child.hasRegistration('Config')).toBe(true);
    // Child should see its own registration
    expect(child.hasRegistration('Service')).toBe(true);
    // Parent should see its own registration
    expect(parent.hasRegistration('Config')).toBe(true);
  });

  it('should work with class-based registrations', function () {
    @register(bindTo('ILogger'))
    class Logger {}

    const container = createAppContainer();
    container.addRegistration(R.fromClass(Logger));

    expect(container.hasRegistration('ILogger')).toBe(true);
  });

  it('should be useful for conditional registration patterns', function () {
    const container = createAppContainer();

    // Register a base service
    container.addRegistration(R.fromValue('base-service').bindToKey('BaseService'));

    // Conditionally register an extension only if base exists
    if (container.hasRegistration('BaseService')) {
      container.addRegistration(R.fromValue('extension-service').bindToKey('ExtensionService'));
    }

    expect(container.hasRegistration('BaseService')).toBe(true);
    expect(container.hasRegistration('ExtensionService')).toBe(true);
  });
});
import { Container, Registration as R, bindTo, register, SingleToken } from 'ts-ioc-container';

/**
 * Container Registration Checking - hasRegistration
 *
 * The `hasRegistration` method allows you to check if a registration with a specific key
 * exists in the current container. This is useful for conditional registration logic,
 * validation, and debugging.
 *
 * Key points:
 * - Checks only the current container's registrations (not parent containers)
 * - Works with string keys, symbol keys, and token keys
 * - Returns false after container disposal
 * - Useful for conditional registration patterns
 */
describe('hasRegistration', function () {
  const createAppContainer = () => new Container({ tags: ['application'] });

  it('should return true when registration exists with string key', function () {
    const container = createAppContainer();
    container.addRegistration(R.fromValue('production').bindToKey('Environment'));

    expect(container.hasRegistration('Environment')).toBe(true);
  });

  it('should return false when registration does not exist', function () {
    const container = createAppContainer();

    expect(container.hasRegistration('NonExistentService')).toBe(false);
  });

  it('should work with symbol keys', function () {
    const container = createAppContainer();
    const serviceKey = Symbol('IService');
    container.addRegistration(R.fromValue({ name: 'Service' }).bindToKey(serviceKey));

    expect(container.hasRegistration(serviceKey)).toBe(true);
  });

  it('should work with token keys', function () {
    const container = createAppContainer();
    const loggerToken = new SingleToken<{ log: (msg: string) => void }>('ILogger');
    container.addRegistration(R.fromValue({ log: () => {} }).bindTo(loggerToken));

    expect(container.hasRegistration(loggerToken.token)).toBe(true);
  });

  it('should check current container and parent registrations', function () {
    // Parent container has a registration
    const parent = createAppContainer();
    parent.addRegistration(R.fromValue('parent-config').bindToKey('Config'));

    // Child scope does not have the registration
    const child = parent.createScope();
    child.addRegistration(R.fromValue('child-service').bindToKey('Service'));

    // Child should see parent's registration (checks parent as well)
    expect(child.hasRegistration('Config')).toBe(true);
    // Child should see its own registration
    expect(child.hasRegistration('Service')).toBe(true);
    // Parent should see its own registration
    expect(parent.hasRegistration('Config')).toBe(true);
  });

  it('should work with class-based registrations', function () {
    @register(bindTo('ILogger'))
    class Logger {}

    const container = createAppContainer();
    container.addRegistration(R.fromClass(Logger));

    expect(container.hasRegistration('ILogger')).toBe(true);
  });

  it('should be useful for conditional registration patterns', function () {
    const container = createAppContainer();

    // Register a base service
    container.addRegistration(R.fromValue('base-service').bindToKey('BaseService'));

    // Conditionally register an extension only if base exists
    if (container.hasRegistration('BaseService')) {
      container.addRegistration(R.fromValue('extension-service').bindToKey('ExtensionService'));
    }

    expect(container.hasRegistration('BaseService')).toBe(true);
    expect(container.hasRegistration('ExtensionService')).toBe(true);
  });
});

Use Cases

Proper resource cleanup is essential for preventing memory leaks. The container provides a disposal mechanism that cleans up all resources and prevents further usage.

TypeScript __tests__/readme/disposing.spec.ts
import 'reflect-metadata';
import { Container, ContainerDisposedError, 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
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).bindTo('IDatabase'),
    );

    // 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).bindTo('IDatabase'),
    );

    // 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();
  });
});
import 'reflect-metadata';
import { Container, ContainerDisposedError, 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
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).bindTo('IDatabase'),
    );

    // 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).bindTo('IDatabase'),
    );

    // 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();
  });
});

Disposal Behavior

Lazy loading defers the instantiation of dependencies until they’re actually accessed. This can improve startup performance and enable circular dependency resolution.

TypeScript __tests__/readme/lazy.spec.ts
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!
  });
});
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!
  });
});

The container provides explicit disposal to prevent memory leaks:

Container modules encapsulate registration logic, making it easy to organize and compose your dependency configuration. This is particularly useful for environment-specific setups or feature-based organization.

Creating a Module

Modules implement the IContainerModule interface and use the applyTo method to register dependencies:

TypeScript __tests__/readme/containerModule.spec.ts
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 === 'admin@example.com' && 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('admin@example.com', 'secure_password')).toBe(true);
    expect(auth.authenticate('admin@example.com', '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('any@email.com', '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');
  });
});
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 === 'admin@example.com' && 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('admin@example.com', 'secure_password')).toBe(true);
    expect(auth.authenticate('admin@example.com', '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('any@email.com', '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');
  });
});

Use Cases

1. Separation of Concerns

The architecture separates three distinct responsibilities:

This separation makes each component testable and replaceable independently.

2. Scope-Based Isolation

Scopes provide isolation while maintaining a parent-child relationship. This design enables:

3. Tag-Based Scoping

Tags provide a flexible way to control provider availability without hardcoding scope relationships. This:

4. Composite Pattern

The scope hierarchy uses the Composite pattern, where containers can contain other containers. This allows uniform treatment of individual containers and container hierarchies.

5. Null Object Pattern

The EmptyContainer class implements the Null Object pattern, providing a safe default parent for root containers. This eliminates null checks throughout the codebase.

Container Methods