Pipes
Goal: Provide composable transformations for both Providers and Registrations.
Pipes are the foundation of the IoC container’s composability. They allow you to transform providers and registrations with features like singleton caching, lazy loading, argument injection, and more. Understanding pipes is essential for building flexible, maintainable dependency injection systems.
What are Pipes?
Pipes are transformation functions that modify how providers create instances or how registrations are configured. Think of them as middleware that wraps your dependencies with additional behavior.
There are two types of pipes:
- ProviderPipe: Can transform both providers and registrations
- RegisterPipe: Functions that only work at the registration level
ProviderPipe vs RegisterPipe
ProviderPipe Interface
A ProviderPipe is an object with two transformation methods:
export interface ProviderPipe<T = unknown> {
mapProvider(p: IProvider<T>): IProvider<T>; // Transforms a Provider
mapRegistration(r: IRegistration<T>): IRegistration<T>; // Transforms a Registration
}export interface ProviderPipe<T = unknown> {
mapProvider(p: IProvider<T>): IProvider<T>; // Transforms a Provider
mapRegistration(r: IRegistration<T>): IRegistration<T>; // Transforms a Registration
}This dual nature allows ProviderPipes to work seamlessly with:
- Provider.pipe() - Uses
mapProviderto transform the provider - Registration.pipe() - Uses
mapProviderinternally - @register() decorator - Uses
mapRegistrationto transform the registration
registerPipe Helper Factory
registerPipe is a helper function that creates ProviderPipe objects from a simple transformation function:
export const registerPipe = <T>(
mapProvider: (p: IProvider<T>) => IProvider<T>
): ProviderPipe<T> => ({
mapProvider,
mapRegistration: (r) => r.pipe(mapProvider),
});export const registerPipe = <T>(
mapProvider: (p: IProvider<T>) => IProvider<T>
): ProviderPipe<T> => ({
mapProvider,
mapRegistration: (r) => r.pipe(mapProvider),
});Key Insight: All built-in pipe functions (singleton(), lazy(), args(), etc.) use registerPipe() internally. This is why they work everywhere - they’re all ProviderPipes!
MapFn<IRegistration>
Some transformations only make sense at the registration level, like bindTo() and scope(). These are plain functions of type MapFn<IRegistration>:
type MapFn<T> = (value: T) => T;
// Registration-only pipes
export const bindTo = (...tokens: DependencyKey[]): MapFn<IRegistration> =>
(r) => r.bindToKey(...tokens);
export const scope = (...rules: ScopeMatchRule[]): MapFn<IRegistration> =>
(r) => r.when(...rules);type MapFn<T> = (value: T) => T;
// Registration-only pipes
export const bindTo = (...tokens: DependencyKey[]): MapFn<IRegistration> =>
(r) => r.bindToKey(...tokens);
export const scope = (...rules: ScopeMatchRule[]): MapFn<IRegistration> =>
(r) => r.when(...rules);Available Pipes
Here’s a complete reference of all available pipes in the container:
| Pipe Name | ProviderPipe | RegisterPipe | Purpose | File |
|---|---|---|---|---|
singleton() | ✅ | ✅ | Caches single instance per scope | SingletonProvider.ts |
args(...values) | ✅ | ✅ | Injects static arguments into constructor | IProvider.ts |
argsFn(fn) | ✅ | ✅ | Injects dynamically resolved arguments | IProvider.ts |
lazy() | ✅ | ✅ | Defers instantiation until first access | IProvider.ts |
scopeAccess(rule) | ✅ | ✅ | Controls visibility based on scope rules | IProvider.ts |
decorate(fn) | ✅ | ✅ | Wraps instance with decorator function | DecoratorProvider.ts |
scope(...rules) | ❌ | ✅ | Determines which scopes registration applies to | IRegistration.ts |
bindTo(...tokens) | ❌ | ✅ | Binds registration to dependency keys | IRegistration.ts |
Legend:
- ✅ ProviderPipe: Can be used in
.pipe()on IProvider and IRegistration - ✅ RegisterPipe: Can be used in
@register()decorator and.pipe()on IRegistration - ❌ Not a ProviderPipe (registration-only)
Note: The last two (scope() and bindTo()) are MapFn<IRegistration> functions that only work at the registration level, not the provider level.
Usage Examples
Using ProviderPipes with @register Decorator
ProviderPipes work seamlessly with the @register decorator:
import { register, bindTo, singleton, scopeAccess } from 'ts-ioc-container';
@register(
bindTo('IPasswordHasher'), // RegisterPipe (registration-only)
singleton(), // ProviderPipe (works everywhere)
scopeAccess((opts) => // ProviderPipe (works everywhere)
opts.invocationScope.hasTag('authenticated')
)
)
class PasswordHasher {
private readonly salt: string;
constructor() {
this.salt = 'random_salt_' + Math.random().toString(36);
}
hash(password: string): string {
return `hashed_${password}_${this.salt}`;
}
}import { register, bindTo, singleton, scopeAccess } from 'ts-ioc-container';
@register(
bindTo('IPasswordHasher'), // RegisterPipe (registration-only)
singleton(), // ProviderPipe (works everywhere)
scopeAccess((opts) => // ProviderPipe (works everywhere)
opts.invocationScope.hasTag('authenticated')
)
)
class PasswordHasher {
private readonly salt: string;
constructor() {
this.salt = 'random_salt_' + Math.random().toString(36);
}
hash(password: string): string {
return `hashed_${password}_${this.salt}`;
}
}Using Pipes with Provider.pipe()
When creating providers manually, you can chain multiple pipes:
import { Container, Provider as R, singleton, lazy, args } from 'ts-ioc-container';
const container = new Container().addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(
singleton(), // ProviderPipe - cache instance
lazy(), // ProviderPipe - defer creation
args('smtp.gmail.com') // ProviderPipe - inject argument
)
);import { Container, Provider as R, singleton, lazy, args } from 'ts-ioc-container';
const container = new Container().addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(
singleton(), // ProviderPipe - cache instance
lazy(), // ProviderPipe - defer creation
args('smtp.gmail.com') // ProviderPipe - inject argument
)
);Using Pipes with Registration.pipe()
Registrations can also use pipes, accepting both ProviderPipes and registration-specific pipes:
import { Container, Registration as R, bindTo, scope, singleton } from 'ts-ioc-container';
const container = new Container().addRegistration(
R.fromClass(ConfigService)
.pipe(
bindTo('Config'), // Registration pipe
scope((c) => c.hasTag('application')), // Registration pipe
singleton() // Provider pipe (auto-converted)
)
);import { Container, Registration as R, bindTo, scope, singleton } from 'ts-ioc-container';
const container = new Container().addRegistration(
R.fromClass(ConfigService)
.pipe(
bindTo('Config'), // Registration pipe
scope((c) => c.hasTag('application')), // Registration pipe
singleton() // Provider pipe (auto-converted)
)
);Mixing ProviderPipes and Raw Functions
You can mix ProviderPipe objects with raw transformation functions:
import { Provider as R, lazy, singleton } from 'ts-ioc-container';
R.fromClass(Service)
.pipe(
(p) => p.setArgs(() => ['arg1', 'arg2']), // Raw function
lazy(), // ProviderPipe
singleton() // ProviderPipe
)import { Provider as R, lazy, singleton } from 'ts-ioc-container';
R.fromClass(Service)
.pipe(
(p) => p.setArgs(() => ['arg1', 'arg2']), // Raw function
lazy(), // ProviderPipe
singleton() // ProviderPipe
)Pipe Composition
Pipes use the Decorator pattern to compose transformations. Each pipe wraps the previous provider with additional functionality:
const provider = Provider.fromClass(Logger)
.pipe(args('/var/log/app.log')) // 1. Bind arguments
.pipe(singleton()) // 2. Cache instances
.pipe(scopeAccess(adminOnly)) // 3. Control visibility
.pipe(lazy()); // 4. Defer creation
// Each pipe wraps the previous provider:
// Final provider: Lazy(ScopeAccess(Singleton(Args(Base))))const provider = Provider.fromClass(Logger)
.pipe(args('/var/log/app.log')) // 1. Bind arguments
.pipe(singleton()) // 2. Cache instances
.pipe(scopeAccess(adminOnly)) // 3. Control visibility
.pipe(lazy()); // 4. Defer creation
// Each pipe wraps the previous provider:
// Final provider: Lazy(ScopeAccess(Singleton(Args(Base))))Pipe Order
In most cases, pipe order does not matter. The container is designed so that pipes like singleton(), lazy(), args(), argsFn(), and scopeAccess() can be applied in any order and will work correctly.
// Order doesn't matter for most pipes
R.fromClass(Service)
.pipe(singleton(), lazy(), args('config.json')) // ✅ Works
R.fromClass(Service)
.pipe(args('config.json'), singleton(), lazy()) // ✅ Also works
R.fromClass(Service)
.pipe(lazy(), scopeAccess(adminOnly), singleton()) // ✅ Also works// Order doesn't matter for most pipes
R.fromClass(Service)
.pipe(singleton(), lazy(), args('config.json')) // ✅ Works
R.fromClass(Service)
.pipe(args('config.json'), singleton(), lazy()) // ✅ Also works
R.fromClass(Service)
.pipe(lazy(), scopeAccess(adminOnly), singleton()) // ✅ Also worksException: decorate() Pipe
The decorate() pipe is the exception where order matters. This is because decorate() wraps the instance after creation, so its position relative to lazy() determines what gets decorated:
// decorate() BEFORE lazy() - decorates the real instance
R.fromClass(Service)
.pipe(
decorate(withLogging), // Wraps the actual Service instance
lazy() // Returns lazy proxy to decorated instance
)
// decorate() AFTER lazy() - decorates the lazy proxy
R.fromClass(Service)
.pipe(
lazy(), // Creates lazy proxy first
decorate(withLogging) // Wraps the proxy, not the real instance
)// decorate() BEFORE lazy() - decorates the real instance
R.fromClass(Service)
.pipe(
decorate(withLogging), // Wraps the actual Service instance
lazy() // Returns lazy proxy to decorated instance
)
// decorate() AFTER lazy() - decorates the lazy proxy
R.fromClass(Service)
.pipe(
lazy(), // Creates lazy proxy first
decorate(withLogging) // Wraps the proxy, not the real instance
)Creating Custom Pipes
Creating a ProviderPipe
Use the registerPipe helper to create custom ProviderPipes:
import { registerPipe, IProvider } from 'ts-ioc-container';
// Custom pipe that logs instance creation
export const logCreation = <T>() => registerPipe<T>((provider: IProvider<T>) => {
return new Provider((container, options) => {
console.log(`Creating instance of ${provider.constructor.name}`);
const instance = provider.resolve(container, options);
console.log(`Created instance:`, instance);
return instance;
});
});
// Usage
@register(bindTo('MyService'), logCreation())
class MyService {}import { registerPipe, IProvider } from 'ts-ioc-container';
// Custom pipe that logs instance creation
export const logCreation = <T>() => registerPipe<T>((provider: IProvider<T>) => {
return new Provider((container, options) => {
console.log(`Creating instance of ${provider.constructor.name}`);
const instance = provider.resolve(container, options);
console.log(`Created instance:`, instance);
return instance;
});
});
// Usage
@register(bindTo('MyService'), logCreation())
class MyService {}Creating a Registration-Only Pipe
For registration-specific transformations, create a MapFn<IRegistration>:
import { IRegistration, MapFn } from 'ts-ioc-container';
// Custom pipe that adds multiple tags
export const withTags = (...tags: string[]): MapFn<IRegistration> =>
(r) => {
// Add custom metadata or transformation
return r.pipe(
scope((c) => tags.some(tag => c.hasTag(tag)))
);
};
// Usage
@register(
bindTo('Service'),
withTags('admin', 'authenticated')
)
class SecureService {}import { IRegistration, MapFn } from 'ts-ioc-container';
// Custom pipe that adds multiple tags
export const withTags = (...tags: string[]): MapFn<IRegistration> =>
(r) => {
// Add custom metadata or transformation
return r.pipe(
scope((c) => tags.some(tag => c.hasTag(tag)))
);
};
// Usage
@register(
bindTo('Service'),
withTags('admin', 'authenticated')
)
class SecureService {}Built-in Pipes Reference
singleton()
Caches a single instance per scope. Essential for expensive resources.
import { singleton } from 'ts-ioc-container';
@register(bindTo('DbConnection'), singleton())
class DatabaseConnection {
constructor() {
console.log('Expensive DB connection created');
}
}
// Only logs once per scope
const db1 = container.resolve('DbConnection');
const db2 = container.resolve('DbConnection');
console.log(db1 === db2); // trueimport { singleton } from 'ts-ioc-container';
@register(bindTo('DbConnection'), singleton())
class DatabaseConnection {
constructor() {
console.log('Expensive DB connection created');
}
}
// Only logs once per scope
const db1 = container.resolve('DbConnection');
const db2 = container.resolve('DbConnection');
console.log(db1 === db2); // trueargs(…values)
Injects static arguments into constructor.
import { args } from 'ts-ioc-container';
class Logger {
constructor(public filename: string) {}
}
container.addRegistration(
R.fromClass(Logger).pipe(args('/var/log/app.log'))
);
const logger = container.resolve<Logger>('Logger');
console.log(logger.filename); // '/var/log/app.log'import { args } from 'ts-ioc-container';
class Logger {
constructor(public filename: string) {}
}
container.addRegistration(
R.fromClass(Logger).pipe(args('/var/log/app.log'))
);
const logger = container.resolve<Logger>('Logger');
console.log(logger.filename); // '/var/log/app.log'argsFn(fn)
Injects dynamically resolved arguments.
import { argsFn } from 'ts-ioc-container';
class Service {
constructor(public env: string) {}
}
container
.addRegistration(R.fromClass(Config))
.addRegistration(
R.fromClass(Service).pipe(
argsFn((scope) => [scope.resolve<Config>('Config').env])
)
);import { argsFn } from 'ts-ioc-container';
class Service {
constructor(public env: string) {}
}
container
.addRegistration(R.fromClass(Config))
.addRegistration(
R.fromClass(Service).pipe(
argsFn((scope) => [scope.resolve<Config>('Config').env])
)
);lazy()
Defers instantiation until first access using JavaScript Proxies.
import { lazy, singleton } from 'ts-ioc-container';
@register(bindTo('ExpensiveService'), lazy(), singleton())
class ExpensiveService {
constructor() {
console.log('Expensive initialization');
}
doWork() {
console.log('Working...');
}
}
const service = container.resolve('ExpensiveService');
// Nothing logged yet - proxy returned
service.doWork();
// Now logs: "Expensive initialization"
// Then logs: "Working..."import { lazy, singleton } from 'ts-ioc-container';
@register(bindTo('ExpensiveService'), lazy(), singleton())
class ExpensiveService {
constructor() {
console.log('Expensive initialization');
}
doWork() {
console.log('Working...');
}
}
const service = container.resolve('ExpensiveService');
// Nothing logged yet - proxy returned
service.doWork();
// Now logs: "Expensive initialization"
// Then logs: "Working..."scopeAccess(rule)
Controls which scopes can access a dependency.
import { scopeAccess } from 'ts-ioc-container';
@register(
bindTo('AdminService'),
scopeAccess((opts) => opts.invocationScope.hasTag('admin'))
)
class AdminService {}
const adminScope = container.createScope({ tags: ['admin'] });
const userScope = container.createScope({ tags: ['user'] });
adminScope.resolve('AdminService'); // ✅ Works
userScope.resolve('AdminService'); // ❌ Throws DependencyNotFoundErrorimport { scopeAccess } from 'ts-ioc-container';
@register(
bindTo('AdminService'),
scopeAccess((opts) => opts.invocationScope.hasTag('admin'))
)
class AdminService {}
const adminScope = container.createScope({ tags: ['admin'] });
const userScope = container.createScope({ tags: ['user'] });
adminScope.resolve('AdminService'); // ✅ Works
userScope.resolve('AdminService'); // ❌ Throws DependencyNotFoundErrordecorate(fn)
Wraps instances with additional behavior using the Decorator pattern.
import { decorate } from 'ts-ioc-container';
interface IRepository {
save(item: unknown): Promise<void>;
}
const withLogging = <T extends IRepository>(instance: T): T => {
return new Proxy(instance, {
get(target, prop) {
if (prop === 'save') {
return async (...args: unknown[]) => {
console.log('Saving:', args);
const result = await target[prop](...args);
console.log('Saved successfully');
return result;
};
}
return target[prop as keyof T];
}
});
};
@register(bindTo('IRepository'), decorate(withLogging))
class TodoRepository implements IRepository {
async save(item: unknown): Promise<void> {
// Database save logic
}
}import { decorate } from 'ts-ioc-container';
interface IRepository {
save(item: unknown): Promise<void>;
}
const withLogging = <T extends IRepository>(instance: T): T => {
return new Proxy(instance, {
get(target, prop) {
if (prop === 'save') {
return async (...args: unknown[]) => {
console.log('Saving:', args);
const result = await target[prop](...args);
console.log('Saved successfully');
return result;
};
}
return target[prop as keyof T];
}
});
};
@register(bindTo('IRepository'), decorate(withLogging))
class TodoRepository implements IRepository {
async save(item: unknown): Promise<void> {
// Database save logic
}
}scope(…rules)
Determines which container scopes should have this registration.
import { scope } from 'ts-ioc-container';
@register(
bindTo('RequestLogger'),
scope((c) => c.hasTag('request')),
singleton()
)
class RequestLogger {}
const appContainer = new Container({ tags: ['application'] });
const requestScope = appContainer.createScope({ tags: ['request'] });
appContainer.resolve('RequestLogger'); // ❌ Not registered here
requestScope.resolve('RequestLogger'); // ✅ Available in request scopeimport { scope } from 'ts-ioc-container';
@register(
bindTo('RequestLogger'),
scope((c) => c.hasTag('request')),
singleton()
)
class RequestLogger {}
const appContainer = new Container({ tags: ['application'] });
const requestScope = appContainer.createScope({ tags: ['request'] });
appContainer.resolve('RequestLogger'); // ❌ Not registered here
requestScope.resolve('RequestLogger'); // ✅ Available in request scopebindTo(…tokens)
Binds a registration to one or more dependency keys.
import { bindTo } from 'ts-ioc-container';
@register(
bindTo('ILogger', 'Logger', 'ConsoleLogger'),
singleton()
)
class Logger {}
// All three keys resolve to same instance
container.resolve('ILogger');
container.resolve('Logger');
container.resolve('ConsoleLogger');import { bindTo } from 'ts-ioc-container';
@register(
bindTo('ILogger', 'Logger', 'ConsoleLogger'),
singleton()
)
class Logger {}
// All three keys resolve to same instance
container.resolve('ILogger');
container.resolve('Logger');
container.resolve('ConsoleLogger');Common Patterns
Request-Scoped Singleton
Singleton per request, but different instances across requests:
@register(
bindTo('SessionService'),
scope((c) => c.hasTag('request')),
singleton()
)
class SessionService {}@register(
bindTo('SessionService'),
scope((c) => c.hasTag('request')),
singleton()
)
class SessionService {}Lazy Singleton
Defer creation until access, but only create once:
@register(
bindTo('DatabaseConnection'),
lazy(),
singleton()
)
class DatabaseConnection {}@register(
bindTo('DatabaseConnection'),
lazy(),
singleton()
)
class DatabaseConnection {}Conditional Registration
Register only in specific environments:
@register(
bindTo('DebugService'),
scope((c) => c.hasTag('development')),
singleton()
)
class DebugService {}@register(
bindTo('DebugService'),
scope((c) => c.hasTag('development')),
singleton()
)
class DebugService {}Decorated Singleton
Add cross-cutting concerns to cached instances:
@register(
bindTo('ApiClient'),
decorate(withRetry),
decorate(withLogging),
singleton()
)
class ApiClient {}@register(
bindTo('ApiClient'),
decorate(withRetry),
decorate(withLogging),
singleton()
)
class ApiClient {}