Skip to content

Factories

Use registerFactory when construction needs more than new Ctor(...deps): reading multiple values, adapting third-party clients, creating configuration objects, or returning a promise.

ts
const container = new Container()
  .registerValue('config', { dsn: 'postgres://localhost/app', poolSize: 10 })
  .registerFactory('pgPool', (c) => {
    const { dsn, poolSize } = c.get('config')
    return new Pool({ connectionString: dsn, max: poolSize })
  })
  .registerClass('users', UserRepo, ['pgPool'])

The factory return value becomes the key's resolved type.

Hot Transient Graphs

registerClass is the default for transient services. Keep it unless profiling identifies construction as a meaningful part of a hot path.

V8 can slow a narrow pattern: one graph repeatedly resolves many different transient classes that have the same dependency count. Register only those measured services with factories when the application artifact confirms the hotspot:

ts
const container = new Container()
  .registerClass('context', RequestContext, [], 'scoped')
  .registerClass('schema', Schema, [])
  .registerFactory(
    'parseRequest',
    (c) => new ParseRequest(c.get('context'), c.get('schema')),
    'transient',
  )

Each factory should contain its own new Service(...) call. Do not route several services through one generic construction helper if this optimization matters. Factories repeat dependency wiring, so use them for measured hotspots rather than converting every transient registration.

Factory Lifetimes

Factories use the same lifetime model as classes:

ts
const root = new Container()
  .registerFactory('cache', () => new Cache(), 'singleton')
  .registerFactory('request', () => new RequestState(), 'scoped')

Inside a singleton factory, the c parameter is narrowed to singleton-safe dependencies. Scoped and transient keys do not autocomplete and are rejected by TypeScript.

Pass an optional fourth lazyKey to register a lifetime-preserving Lazy<V> companion, exactly as with registerClass:

ts
const root = new Container()
  .registerFactory('cache', () => new Cache(), 'singleton', 'cacheLazy')

root.get('cacheLazy').get() // Cache

When using the default singleton lifetime, pass undefined before the companion key: registerFactory('cache', factory, undefined, 'cacheLazy').

Binding Interfaces

TypeScript interfaces are erased during compilation and have no runtime value to pass as a constructor. Bind an interface to its implementation through an explicit factory type instead:

ts
interface Mailer {
  send(message: string): void
}

class SendGridMailer implements Mailer {
  send(message: string) {}
}

const container = new Container()
  .registerFactory<'mailer', Mailer>('mailer', () => new SendGridMailer())

Consumers of 'mailer' see the Mailer abstraction, not the concrete class.

Async Factories

Factories may return promises. The promise itself is cached, so concurrent callers share initialization:

ts
const c = new Container()
  .registerValue('dsn', 'postgres://localhost/app')
  .registerFactory('db', async (c) => {
    const pool = new Pool({ connectionString: c.get('dsn') })
    await pool.connect()
    return pool
  })

const [a, b] = await Promise.all([c.get('db'), c.get('db')])
await c.dispose()

.get() stays synchronous. Callers await the returned value when the registration is async.

The runtime cycle and lifetime guards project only the synchronous factory call stack. After await, AllowedDeps still protects normal typed code, but an as-cast or captured outer container is outside runtime guard context. Keep dependency reads in the synchronous factory prelude.