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:

Note: The last two (scope() and bindTo()) are MapFn<IRegistration> functions that only work at the registration level, not the provider level.

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)
  .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:

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

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

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.

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 instantiation until first access using JavaScript Proxies.

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