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.

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 Interface

A ProviderPipe is an object with two transformation methods:

TypeScript
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:

registerPipe Helper Factory

registerPipe is a helper function that creates ProviderPipe objects from a simple transformation function:

TypeScript
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>:

TypeScript
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);

Here’s a complete reference of all available pipes in the container:

Pipe NameProviderPipeRegisterPipePurposeFile
singleton()Caches single instance per scopeSingletonProvider.ts
args(...values)Injects static arguments into constructorIProvider.ts
argsFn(fn)Injects dynamically resolved argumentsIProvider.ts
lazy()Defers instantiation until first accessIProvider.ts
scopeAccess(rule)Controls visibility based on scope rulesIProvider.ts
decorate(fn)Wraps instance with decorator functionDecoratorProvider.ts
scope(...rules)Determines which scopes registration applies toIRegistration.ts
bindTo(...tokens)Binds registration to dependency keysIRegistration.ts

Legend:

[!IMPORTANT]

scope() and bindTo() are registration-only functions. They work at registration time, not on an already-created provider.

Both helpers use scope information, but they answer different questions in different parts of the model:

QuestionUse scope()Use scopeAccess()
Where does it belong?RegistrationProvider
Rule typeScopeMatchRuleScopeAccessRule
Question it answersCan this registration apply to this scope?Can this provider be resolved from this scope and with this invocation scope (initial resolution scope)?
What does it define?Registration application limitsProvider resolving limits
When does it run?When addRegistration() applies a registration, and when createScope() replays registrations into a new scopeWhen a dependency is being resolved and a container has a provider for the requested key or alias
Scope valuesThe candidate scope receiving the registrationproviderScope, the container currently checking the provider, and invocationScope, the original scope that started resolution
What does a false result mean?The registration is not applied to that containerThat provider cannot satisfy this invocation, so resolution continues upward or fails if no accessible provider exists
Where can it be used?@register(...) and Registration.pipe(...)@register(...), Registration.pipe(...), and Provider.pipe(...)
Typical usePer-request services, environment-specific implementations, per-scope singleton placementAdmin-only visibility, app-only secrets, hiding parent providers from child request scopes

Use scope() when you are deciding which containers a registration can apply to. Use scopeAccess() when a provider is available during resolution, but the dependency should only be resolved for selected invocation containers. The rule also receives providerScope, which is the container currently checking that provider; it can differ from invocationScope when a child container does not have the provider and resolution continues to a parent that does.

TypeScript
import { bindTo, register, scope, scopeAccess, singleton } from 'ts-ioc-container';

// Registration application limit:
// Can this registration apply to this scope?
@register(
bindTo('RequestLogger'),
scope((s) => s.hasTag('request')),
singleton()
)
class RequestLogger {}

// Resolving limit:
// Can this provider be resolved from this scope and with this
// invocation scope (initial resolution scope)?
@register(
bindTo('AdminConsole'),
scope((s) => s.hasTag('application')),
scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin'))
)
class AdminConsole {}
import { bindTo, register, scope, scopeAccess, singleton } from 'ts-ioc-container';

// Registration application limit:
// Can this registration apply to this scope?
@register(
bindTo('RequestLogger'),
scope((s) => s.hasTag('request')),
singleton()
)
class RequestLogger {}

// Resolving limit:
// Can this provider be resolved from this scope and with this
// invocation scope (initial resolution scope)?
@register(
bindTo('AdminConsole'),
scope((s) => s.hasTag('application')),
scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin'))
)
class AdminConsole {}
[!WARNING]

scope((s) => s.hasTag(‘application’)) only answers whether the registration can apply to the application scope. It does not make the dependency resolvable only from the application container. Child scopes can still find parent providers through upward resolution, where providerScope is the parent currently checking the provider and invocationScope is the child that started resolution. Add a scopeAccess() rule that compares those scopes when only the same container should resolve it.

Using ProviderPipes with @register Decorator

ProviderPipes work seamlessly with the @register decorator:

TypeScript
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:

TypeScript
import { Container, Provider as R, singleton, lazy, args } from 'ts-ioc-container';

const container = new Container().addRegistration(
R.fromClass(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)
  .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:

TypeScript
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:

TypeScript
import { Provider as R, lazy, singleton } from 'ts-ioc-container';

R.fromClass(Service)
.pipe(
  (p) => p.appendArgs('arg1', 'arg2'),       // Raw function
  lazy(),                                      // ProviderPipe
  singleton()                                  // ProviderPipe
)
import { Provider as R, lazy, singleton } from 'ts-ioc-container';

R.fromClass(Service)
.pipe(
  (p) => p.appendArgs('arg1', 'arg2'),       // Raw function
  lazy(),                                      // ProviderPipe
  singleton()                                  // ProviderPipe
)

Pipes use the Decorator pattern to compose transformations. Each pipe wraps the previous provider with additional functionality:

TypeScript
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

[!IMPORTANT]

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.

[!WARNING]

Pipe order can be observable for wrappers such as decorate() and for custom pipes. Document custom pipe ordering expectations.

TypeScript
// 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 works

Exception: 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:

TypeScript
// 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 a ProviderPipe

Use the registerPipe helper to create custom ProviderPipes:

TypeScript
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>:

TypeScript
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 {}

singleton()

Caches a single instance per scope. Essential for expensive resources.

TypeScript
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); // true
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); // true

args(…values)

Injects static arguments into constructor.

TypeScript
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.

TypeScript
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 class instance creation until first access using JavaScript Proxies. lazy() is designed only for class instances; do not use it for primitive values, plain values, functions, or non-class provider results.

[!IMPORTANT]

lazy() is a provider pipe for class instance creation.

[!WARNING]

Do not apply lazy() to value providers, function providers, primitive values, or other non-class provider results.

TypeScript
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.

TypeScript
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 DependencyNotFoundError
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 DependencyNotFoundError

decorate(fn)

Wraps instances with additional behavior using the Decorator pattern.

TypeScript
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.

TypeScript
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 scope
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 scope

bindTo(…tokens)

Binds a registration to one or more dependency keys.

TypeScript
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');

Request-Scoped Singleton

Singleton per request, but different instances across requests:

TypeScript
@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:

TypeScript
@register(
bindTo('DatabaseConnection'),
lazy(),
singleton()
)
class DatabaseConnection {}
@register(
bindTo('DatabaseConnection'),
lazy(),
singleton()
)
class DatabaseConnection {}

Conditional Registration

Register only in specific environments:

TypeScript
@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:

TypeScript
@register(
bindTo('ApiClient'),
decorate(withRetry),
decorate(withLogging),
singleton()
)
class ApiClient {}
@register(
bindTo('ApiClient'),
decorate(withRetry),
decorate(withLogging),
singleton()
)
class ApiClient {}