Skip to content

Learn DI Bag ​

Install DI Bag · API reference · Server integration · Runnable examples

DI Bag connects ordinary TypeScript factories, checks their dependency shapes, creates services when they are first requested, and releases the resources that an application explicitly gives it. This guide starts with a small application and then introduces the APIs in the order in which most applications need them.

Examples use DiBag from di-bag, which configures itself on Node, Bun, and Deno; for browsers and workers see portable mode. Complete examples are labeled Standalone. Shorter snippets illustrate individual operations or build on the declarations in their surrounding section. Run repository examples from the repository root with bun run examples/<name>.ts.

Learning path ​

  1. Compose and resolve services.
  2. Learn how async work, ownership, and failures behave.
  3. Choose between tracked child scopes, independent forks, and lifetimes.
  4. Package a feature as a module.
  5. Use typed tokens and positional adapters when names and object parameters do not fit.
  6. Add optional or lazy dependencies, aliases, and ordered collections.
  7. Introduce projections, metadata and inspection, observers, or plugin validation at explicit boundaries.
  8. Read the portable runtime and native provider metadata rules when those environments apply.

The API reference is the compact source for exact signatures, error fields, and exported TypeScript types. This guide concentrates on when to use each operation and what it means for the graph.

Compose services ​

Start with DiBag.createBuilder(), add named factories, and finish the graph with .build(). A factory's object parameter declares its dependencies.

Standalone example:

ts
import { DiBag } from 'di-bag';

const app = DiBag.createBuilder()
  .register({
    config: () => ({ greeting: 'Hello' }),
    greeter: ({ config }: { config: { greeting: string } }) => ({
      greet(name: string) {
        return `${config.greeting}, ${name}!`;
      },
    }),
  })
  .build();

console.log(app.resolve('greeter').greet('Ada')); // Hello, Ada!
await app.close();

createBuilder() returns an immutable Builder. register() returns another builder with new named registrations, and build() checks the complete graph and returns a Bag. The same builder can instead seal a reusable module. Keep the returned builder or chain the call. Registration order does not matter, so a dependency may be added after its consumer. Duplicate names fail; use replace() when changing an existing registration is intentional.

Using the same DiBag import:

ts
const initial = DiBag.createBuilder().register({ clock: () => 42 });
const changed = initial.replace('clock', () => 'ready').build();

changed.resolve('clock'); // inferred as string

replace(nameOrToken, registration) checks the replacement against known consumers and, for a token, against its service contract. It may change a named service's type only while every surviving consumer remains valid. Missing forward dependencies remain allowed until build().

Factories are called without a this receiver. resolve(nameOrToken) lazily creates the selected service and its dependencies. Scoped services are cached, including undefined and an in-flight Promise, so repeated resolutions in one bag return the same value. A factory is still borrowed by default even if its result has a method called close or dispose.

The dependency object is a lazy view, not a plain record. Reading a property acquires that dependency; destructuring in the parameter list is the usual way to do it. Testing 'name' in deps, calling Object.keys(deps), spreading { ...deps }, or serializing it with JSON.stringify throws DI_BAG_INVALID_DEPENDENCY_ACCESS, because those operations would otherwise report an empty object. Read every dependency by name.

DiBag.fromFactory(create, { acquisitionMode }) describes the output stage explicitly. It is useful for deliberate raw Promise-like values and is required by one of the portable-runtime strategies described under portable mode. It does not run the factory or transfer cleanup ownership.

Async edges are explicit ​

An async factory exposes its Promise. A consumer declares that Promise in its dependency type and decides where to await it.

Standalone example:

ts
import { DiBag } from 'di-bag';

const app = DiBag.createBuilder()
  .register({
    number: async () => 21,
    answer: async ({ number }: { number: Promise<number> }) => (await number) * 2,
    synchronous: () => 'ready',
  })
  .build();

const answer: Promise<number> = app.resolve('answer');
console.log(await answer); // 42
console.log(app.resolve('synchronous')); // ready
await app.close();

DI Bag does not await dependency values implicitly or turn synchronous outputs into Promises. Concurrent resolutions share an in-flight cached Promise. A thrown factory error or rejected factory Promise evicts that failed attempt, so a later resolution can retry with a new attempt.

Dependency cycles throw or reject with a path such as cycle: a -> b -> a. Cycle detection also follows dependency reads that happen after an await. Failed attempts keep the work and accepted ownership stages needed for cleanup; they do not redirect their dependency edges to a later retry.

Start selected services and cancel cooperatively ​

Use builder.buildAndStart(keys, options?) when selected services must be ready before the application accepts work. It creates a fresh bag, eagerly acquires only the selection and dependencies, and leaves everything else lazy.

Standalone example:

ts
import { DiBag, DiBagStartupCancelledError } from 'di-bag';

const builder = DiBag.createBuilder().register({
  url: () => 'https://example.com/settings.json',
  settings: DiBag.fromFactory(
    async ({ url }: { url: string }, { signal }) => {
      const response = await fetch(url, { signal });
      return response.text();
    },
    { context: 'acquisition' },
  ),
});

try {
  const app = await builder.buildAndStart(['settings'], {
    timeoutMs: 5_000,
    startupOrder: 'parallel',
  });
  try {
    console.log(await app.resolve('settings'));
  } finally {
    await app.close();
  }
} catch (error) {
  if (error instanceof DiBagStartupCancelledError) {
    await error.cleanupPromise;
  }
  throw error;
}

The selection may contain existing names and typed tokens. Parallel startup is the default; startupOrder: 'sequential' waits in tuple order and does not start later selections after a failure. A positive safe integer, such as startupOrder: 8, limits the number of selected services waiting for readiness at once. Number 1 follows sequential readiness. Numeric scheduling stops admitting queued selections after a failure or cancellation; started work still belongs to the bag and is cleaned up. This bounds selected workers, not dependency fanout inside a provider, and does not await raw exposed thenables. Zero, negative, fractional, nonfinite, and unsafe integer bounds are rejected before factories. An empty selection is valid. Options also accept a genuine external AbortSignal and a finite positive timeoutMs. Invalid options and an already-aborted signal start no factories. Once startup succeeds, the timer and external listener are removed; a later abort of that external signal does not close the bag.

DiBag.fromFactory(factory, { context: 'acquisition' }) passes a frozen acquisition context as the factory's second argument. Each acquisition receives its own context object. Its signal belongs to the bag that owns the attempt. Root services use the family root signal even when a child first asks for them. Closing a scope aborts its signal before draining pending work. Cancellation is cooperative: JavaScript that ignores the signal can keep cleanup pending.

Release a partially acquired resource ​

withDisposal owns the value a factory returns, so a factory that acquires a resource and then fails has nothing to hand over. factoryCtx.pushDisposer(disposer) makes the bag own a resource the factory already holds:

ts
import { DiBag } from 'di-bag';

declare function openPool(): Promise<{ end(): Promise<void>; connect(): Promise<{ close(): Promise<void> }> }>;
declare function handshake(socket: { close(): Promise<void> }): Promise<void>;

const session = DiBag.withDisposal(
  DiBag.fromFactory(async (_deps: {}, factoryCtx) => {
    const pool = await openPool();
    factoryCtx.pushDisposer(() => pool.end());
    const socket = await pool.connect();
    factoryCtx.pushDisposer(disposerCtx => { if (disposerCtx.reason !== 'service-disposed') return socket.close(); });
    await handshake(socket);
    return { socket, close: () => socket.close() };
  }, { context: 'acquisition' }),
  session => session.close(),
);

Each pushed disposer runs exactly once, last pushed first. If the factory throws, rejects, or is cancelled, they run at once — pool.end() after socket.close() — and disposerCtx.reason is 'factory-failed'. If the factory returns, the bag owns them below the returned value: at close(), and at retirement when a later projection fails, every disposer of the service runs first and then the pushed disposers. reason says how the service disposer — the withDisposal on the value this factory returned — went: 'service-disposed', 'service-disposal-failed' (it threw; the pushed disposers still run), or 'no-service-disposer'. Ownership a consumer attaches to a transformed value is not the service disposer; its failures are reported on their own.

withDisposal owns the returned value; pushDisposer owns what is acquired on the way. pool above is released only by its pushed disposer. The socket is the returned value, released by session.close(), so its pushed disposer acts only when reason is not 'service-disposed'. A pushed disposer that ignores disposerCtx runs unconditionally, which is right when nothing else releases the resource.

Every disposer is attempted even when one rejects; each rejection is reported like a close() disposer failure, through cleanup-failed observer events and the DiBagCleanupError of the owning close(). Two shapes deserve a note. A direct projection over an asynchronous source is ready while the source is still running, so a source that then fails runs its pushed disposers at once and its projection's own disposer at close(). A raw asynchronous factory completes when it returns its promise: a disposer pushed before its first await is owned and runs at close(), one pushed after throws, and a later rejection of that promise is not a factory failure. Transient services keep each attempt's pushed disposers until close(), like withDisposal.

Rollback starts one microtask after the factory fails and is not awaited by the failing resolve; close() — or the cleanupPromise of DiBagStartupCancelledError — waits for it to finish. Without a projection the rejection reaches the consumer first. Under transformService or dynamic metadata the pushed disposers can run before the projected promise rejects and before acquisition-failed. pushDisposer belongs to one running factory; calling it on a context retained past that factory throws DI_BAG_CLEANUP_AFTER_FACTORY.

Startup waits according to the selected service's final acquisition mode. A raw Promise or thenable is already a ready value; a native Promise waits for settlement without changing its identity. On acquisition failure, startup closes the new bag and rejects with DiBagStartupError. Abort or timeout rejects promptly with DiBagStartupCancelledError; its cleanupPromise Promise lets the application wait for eventual shutdown, including resources acquired after cancellation.

Attach cleanup with withDisposal ​

Use DiBag.withDisposal(registration, dispose) when the bag should own the value successfully acquired by a registration.

Standalone example:

ts
import { DiBag } from 'di-bag';

const app = DiBag.createBuilder()
  .register({
    cache: DiBag.withDisposal(
      () => new Map<string, string>(),
      (cache) => cache.clear(),
    ),
  })
  .build();

try {
  app.resolve('cache').set('answer', '42');
} finally {
  await app.close();
}

The wrapper describes ownership; neither callback runs when it is constructed. For a native async factory, the disposer receives the fulfilled value. Cleanup may be synchronous or asynchronous. Ordinary factories remain borrowed, and method names never imply ownership. A never-resolved provider owns nothing.

Ownership is additive. If a provider is projected and wrapped again, each accepted stage retains its original value and disposer. Cleanup runs dependents before their dependencies; unrelated resources run in reverse successful acquisition order. Explicitly owning the same object twice runs both finalizers.

bag.close() immediately blocks new public resolutions, scopes, and forks. It aborts the scope signal, waits for pending acquisitions and retired cleanup, and then runs disposers sequentially. Repeated calls return the same Promise and cleanup runs once. A parent closes live child scopes before releasing its own resources. Stop application work before closing: already-returned services cannot be revoked, and a disposer must not await the same bag's close() Promise.

By default close() waits as long as cleanup takes. close({ timeoutMs, signal }) starts the same cleanup but stops waiting when the deadline passes or the signal aborts. It rejects with DiBagCloseCancelledError: code is DI_BAG_CLOSE_TIMEOUT or DI_BAG_CLOSE_ABORTED, details.pending lists the labels of disposers that started and have not finished, details.acquiring lists acquisitions cleanup is still draining, and cleanupPromise settles when cleanup eventually finishes. Scopes and forks accept the same options.

An automatic synchronous stage accepts ordinary values and observes native Promises. A structural thenable returned directly is rejected without invoking its then or transferring ownership. Query builders from libraries such as Knex, Drizzle, or Mongoose are thenables, so a plain factory that returns one is rejected at compile time with factory output is a structural thenable: users; .... Normalize such a value explicitly inside an async boundary, for example () => Promise.resolve(legacyThenable), or select the stage explicitly with DiBag.fromFactory(create, { acquisitionMode: 'raw' }) when the builder object itself is the service. Use a raw stage when the Promise object itself is the owned value. To disable the compile-time check for a whole project, augment the policy interface once:

ts
declare module 'di-bag' {
  interface DiBagPolicy { readonly structuralThenables: 'allow' }
}

The runtime rejection stays in place either way.

Conceptual snippet: pendingPromise, releasePromiseHandle, and the fulfilled resource's close method are application values.

ts
const rawOwned = DiBag.withDisposal(
  DiBag.fromFactory(() => pendingPromise, { acquisitionMode: 'raw' }),
  (promise) => releasePromiseHandle(promise),
);

const fulfilledOwned = DiBag.withDisposal(
  DiBag.fromFactory(() => pendingPromise, { acquisitionMode: 'nativePromise' }),
  (resource) => resource.close(),
);

Raw ownership does not wait for the Promise; native ownership transfers only after fulfillment. A factory must release anything it acquires before it successfully returns an owned value.

Errors and recovery ​

Factory and projection failures keep their original identity. DI Bag errors expose a stable code and frozen details for recovery and telemetry. Cleanup, startup, and plugin failures also have specialized classes. Application exceptions keep their identity and are never relabeled as library errors.

ErrorRecovery information
DiBagCleanupErrorclose() attempted all finalizers. errors holds their original errors, while failures adds acquisitionId, bindingId, label, and error.
DiBagStartupErrorStartup acquisition failed and rollback finished. Read cause, cleanupFailures, and optional cleanupError.
DiBagStartupCancelledErrorStartup was aborted or timed out. Read reason, cause, and await cleanupPromise if shutdown completion matters.
DiBagCloseCancelledErrorclose({ timeoutMs, signal }) stopped waiting. Read code, details.pending, and await cleanupPromise if shutdown completion matters.

Every library-created message has the form <code>: <message>; see https://dany-fedorov.github.io/di-bag/agent/errors.html#<code-slug>, for example DI_BAG_CYCLE: cycle: a -> b -> a; see https://dany-fedorov.github.io/di-bag/agent/errors.html#di-bag-cycle. The linked section explains the cause and the fix. Branch on code and details, not on message text. | DiBagPluginValidationError | A plugin descriptor or output failed validation. phase is 'descriptor' or 'output', and reason explains the rejection. |

Continuation of the cache ownership example:

ts
import { DiBagCleanupError } from 'di-bag';

try {
  await app.close();
} catch (error) {
  if (error instanceof DiBagCleanupError) {
    for (const failure of error.failures) {
      console.error(failure.label, failure.error);
    }
  }
  throw error;
}

When one disposer fails, remaining disposers still run. The bag remains closed, and another close() observes the same rejected Promise. Acquisition failures remain on their resolution Promises; they are not added to a later close error. See the API reference for exact class shapes and constructors.

Read compile-time rejections ​

build(), register(), replace(), fork(), and createScope() reject an invalid graph at compile time. TypeScript reports these as assignability errors whose message names the problem and, where it is cheap to compute, the services involved. Each message ends with the section of the errors page that gives the cause and fix, for example ; see https://dany-fedorov.github.io/di-bag/agent/errors.html#missing-service; the table omits that suffix:

MessageMeaning
required service registrations are missing: clockNo registration supplies clock.
provided service does not satisfy its consumer dependencyA service's type does not match what a consumer declares. verifyGraph() shows the consumer, dependency, expected type, and provided type.
root lifetime cannot capture scoped dependency: db -> configA root service would hold a scoped one.
fork accepts existing names or typed tokens only: unknown extraA selected key is not registered.

The full detail object (expected and provided types, every relationship) is part of the error type. With the default error truncation it prints as { ...; }; set "noErrorTruncation": true in tsconfig.json to read it.

build() errors are anchored where the builder expression starts. To get the verdict on a line of your choice, call verifyGraph(); it does nothing at runtime and its return type is void exactly when the graph would build:

ts
const builder = DiBag.createBuilder().register({
  db: ({ config }: { config: { url: string } }) => config.url,
});
builder.verifyGraph() satisfies void;
// error: Type 'Unsatisfied<"required service registrations are missing: config; see https://dany-fedorov.github.io/di-bag/agent/errors.html#missing-service", { missing: "config"; ... }>' does not satisfy the expected type 'void'.

CompositionReport<typeof builder> is the same verdict as a type, for assertions in test files.

Create tracked child scopes ​

A scope represents work owned by a parent, such as one request or job. The zero-argument form creates fresh scoped acquisitions while automatically using family-root services according to their lifetime.

Standalone example:

ts
import { DiBag } from 'di-bag';

const root = DiBag.createBuilder()
  .register({
    requestId: () => crypto.randomUUID(),
  })
  .build();

const child = root.createScope();
root.resolve('requestId');
child.resolve('requestId'); // a different value, cached by child

await root.close(); // closes the live child first

There are three createScope forms:

  • createScope() creates a tracked child with the same graph.
  • createScope({ share: keys }) also borrows selected parent acquisitions.
  • createScope(keys, overrides, { share: otherKeys }?) replaces selected bindings in the child and may borrow a disjoint selection from the parent.

Standalone example:

ts
import { DiBag } from 'di-bag';

const parent = DiBag.createBuilder()
  .register({
    config: () => ({ region: 'eu' }),
    client: ({ config }: { config: { region: string } }) => ({ region: config.region }),
  })
  .build();

const child = parent.createScope(
  ['config'],
  {
    config: () => ({ region: 'us' }),
  },
  { share: ['client'] },
);

child.resolve('config').region; // us
child.resolve('client') === parent.resolve('client'); // true; client keeps eu
const grandchild = child.createScope({ share: ['client'] });
await parent.close();

Both selections accept existing names and genuine typed tokens. Overrides must preserve the original service contracts. Only selected override properties are read; extra properties cannot alter the graph. A key cannot be selected for both override and sharing.

Sharing borrows the parent's entire acquisition: value, pending Promise, dependencies, metadata, cancellation context, and cleanup ownership. Child shutdown cannot abort or dispose it. Scoped sharing must be selected again in each descendant. Transient services cannot be shared because there is no parent cache to borrow. Root services are inherited automatically.

An inherited root service keeps the graph and dependencies of the scope that defined it, even when a child overrides one of those dependencies. A root override introduced by a child is instead anchored to that child's graph and may be reused by its descendants.

Closing a child independently leaves parent and siblings open. Closing a parent begins closing its live descendants and waits for them before parent finalizers. An independently closed child detaches only after its close settles, so the caller owns any failure from that close.

Fork for scopes and tests ​

Use a fork when the new bag must have independent ownership, memoization, and shutdown. fork() keeps the graph and creates all instances afresh. fork(keys, overrides) also replaces an explicit selection.

Standalone example:

ts
import { DiBag } from 'di-bag';

const app = DiBag.createBuilder()
  .register({
    clock: () => ({ now: () => 42 }),
    stamp: ({ clock }: { clock: { now(): number } }) => clock.now(),
  })
  .build();

const testApp = app.fork(['clock'], {
  clock: () => ({ now: () => 7 }),
});

console.log(testApp.resolve('stamp')); // 7
console.log(app.resolve('stamp')); // 42
await testApp.close();
await app.close();

The selection must be an inline tuple or a separately declared as const tuple. Every selected key must be an own property of the override object. Extra properties are ignored. Explicit selection keeps TypeScript's checked keys equal to the runtime keys despite structural object typing.

Forks are never tracked by their source, including forks created from child scopes. Close each one separately. A fork may replace an owned provider with an ordinary factory to borrow an external value, or add disposal to an ordinary provider. Do not register the same shared value as owned in several bags unless multiple disposal calls are intentional.

WBS-shaped ownership example ​

examples/wbs-scope.ts shows an application-owned source, a root bag, and independently closed batch forks. The following is a conceptual excerpt; scope, openCollector, and the root services are application values defined in that example:

ts
const batch = root.fork(['source', 'clock', 'replayBuffer', 'stores', 'broadcast'], {
  source: () => root.resolve('source'),
  clock: () => root.resolve('clock'),
  replayBuffer: () => root.resolve('replayBuffer'),
  stores: () => scope.stores,
  broadcast: DiBag.withDisposal(openCollector, (collector) => collector.close()),
});

Borrowed values use ordinary factories; the batch owns only its collector. The application closes batches first, then the root, then the source it created. Transaction rollback is application behavior rather than a DI Bag feature.

For request scopes, shutdown signals, Node HTTP, Express, Fastify, Bun, Deno, streaming, WebSockets, jobs, and message consumers, continue with the server integration guide.

Choose root, scoped or transient caching ​

Providers are scoped by default: one acquisition per bag. Wrap a registration with DiBag.withLifetime to choose a family-root cache or a new acquisition for every read.

Standalone example:

ts
import { DiBag } from 'di-bag';

const root = DiBag.createBuilder()
  .register({
    config: DiBag.withLifetime(() => ({ region: 'eu' }), 'root'),
    request: () => ({ id: crypto.randomUUID() }),
    nonce: DiBag.withLifetime(() => ({ value: Math.random() }), 'transient'),
  })
  .build();

const child = root.createScope();
child.resolve('config') === root.resolve('config'); // true
child.resolve('request') === child.resolve('request'); // true
child.resolve('nonce') === child.resolve('nonce'); // false
await root.close();

Lifetime controls caching and attempt ownership; it does not add cleanup. Root attempts belong to the earliest scope that defines the binding, even if a descendant resolves them first. A root override introduced in a child belongs to that child. Scoped and transient attempts belong to the resolving scope or to the owner of the acquisition that requests them. A fork starts a new root family.

A root provider cannot depend on a scoped provider by default because that would capture one scope's value. Use { allowScopedDependencies: true } only for a deliberate root-context capture:

Standalone example:

ts
import { DiBag } from 'di-bag';

const app = DiBag.createBuilder()
  .register({
    rootContext: () => ({ region: 'eu' }),
    client: DiBag.withLifetime(
      ({ rootContext }: { rootContext: { region: string } }) => ({
        region: rootContext.region,
      }),
      'root',
      { allowScopedDependencies: true },
    ),
  })
  .build();

Capture always builds through the root context; it does not borrow a child-owned value. Capture options are valid only for an individually known 'root' lifetime. An outer lifetime wrapper replaces an earlier caching policy. Graph completion and selected scope/fork replacements recheck captive dependencies.

Reuse named modules ​

Modules group a feature's private services and publish only the entry points an application needs. There is no separate module builder: any Builder seals into a module with buildModule(keys), and any builder installs modules.

Standalone example:

ts
import { DiBag } from 'di-bag';

const reports = DiBag.createBuilder()
  .register({
    connection: () => ({ open: true }),
    service: ({
      connection,
      logger,
    }: {
      connection: { open: boolean };
      logger: { log(message: string): void };
    }) => ({
      read() {
        logger.log('read');
        return connection.open;
      },
    }),
  })
  .buildModule(['service']);

const app = DiBag.createBuilder()
  .installModule(reports)
  .register({ logger: () => ({ log: console.log }) })
  .build();

app.resolve('service').read();
await app.close();

buildModule(keys) seals the builder's graph and chooses its public string names and typed tokens. An empty export tuple is valid; contributions are still installed. Where build() rejects a missing dependency, buildModule records it as a requirement the installing host must satisfy. A sealed module cannot resolve, start, or close anything; installModule(module) gives module acquisitions an owning bag.

Modules nest. A builder that has installed modules can seal into a module of its own. Names resolve lexically: an inner module's own registrations first, then the enclosing module's, then the host's. Each installation, at every depth, receives fresh private identities and separate disposal ownership. Requirements an inner module leaves unmet pass outward unless the enclosing module satisfies them; a requirement satisfied by an enclosing export stays checked when the host replaces that export, while one satisfied privately is final.

buildModule(keys, { label: 'reports' }) names each installation's private bindings reports/connection in error messages, cycle paths, inspectGraph(), and observer events. Exported bindings keep their bare key. Labels compose when modules nest: a private state of an inner module installed in an outer module appears as outer/inner/state. Without a label, bindings keep their bare key.

Private providers keep their external requirements, including requirements from providers that are not currently reachable from an export. The host may satisfy them later with register, another installation, or a forward registration before build(). Each installation gets fresh private binding identities and ownership.

module.renameExport(oldName, newName) returns a new export view. It changes a public string lookup name without changing the name used inside factory dependency parameters. Typed-token exports retain their symbol identity and cannot be renamed. Renaming every string export lets an application install the same module twice under distinct public names.

Continuation of the preceding module example:

ts
const eastReports = reports.renameExport('service', 'eastReports');
const westReports = reports.renameExport('service', 'westReports');

const regionalApp = DiBag.createBuilder()
  .installModule(eastReports)
  .installModule(westReports)
  .register({ logger: () => ({ log: console.log }) })
  .build();

Host replace and selected bag overrides are visible to consumers inside the installed module, including its private providers. Module constraints keep those replacements checked. Preserve inferred module types with typeof or ReturnType; see the exported type reference when publishing these contracts from a library.

Use typed tokens for explicit positional injection ​

Names work well for application-owned services. A typed token is useful for a shared contract, a symbol identity, or positional adapters. Give the symbol a canonical const declaration, create one token from it, and export that token when other files need the identity.

Standalone example:

ts
import { DiBag } from 'di-bag';

const clockKey = Symbol('clock');
const clock = DiBag.token(clockKey).of<{ now(): number }>();

const stamp = DiBag.fromFunction([clock], (selectedClock) => selectedClock.now());

const app = DiBag.createBuilder()
  .register(clock, () => ({ now: () => 42 }))
  .register({ stamp })
  .build();

app.resolve(clock).now(); // 42
app.resolve('stamp'); // 42
clock.key === clockKey; // true
await app.close();

token.key is the original symbol and is useful as a computed property in selected overrides. The token handle is the lookup identity; copied, proxied, or fabricated shapes are rejected. Keep both the symbol and token canonical. Do not pass a temporary inline Symbol() call to token; it cannot establish the stable unique-symbol identity required by type admission.

register(token, registration) adds a singular service and verifies that the provider output satisfies the token's service type. The same token can identify contributions as a separate channel, but one channel does not satisfy the other.

DiBag.fromFunction(dependencies, callback, options?) resolves a tuple of tokens and dependency references and calls the callback with values in tuple order. The tuple is captured when the provider is created, callbacks run without a receiver, and no work happens before resolution. The optional acquisition option describes the callback's output stage.

Adapt classes and positional functions ​

Use fromClass and fromFunction for existing code whose constructor or function already takes positional arguments.

Standalone example:

ts
import { DiBag } from 'di-bag';

class Client {
  constructor(private readonly port: number) {}
  address() {
    return `localhost:${this.port}`;
  }
}

function endpoint(client: Client, path: string) {
  return `http://${client.address()}/${path}`;
}

const portKey = Symbol('port');
const clientKey = Symbol('client');
const pathKey = Symbol('path');
const port = DiBag.token(portKey).of<number>();
const client = DiBag.token(clientKey).of<Client>();
const path = DiBag.token(pathKey).of<string>();

const app = DiBag.createBuilder()
  .register(port, () => 8080)
  .register(client, DiBag.fromClass([port], Client))
  .register(path, () => 'health')
  .register({ endpoint: DiBag.fromFunction([client, path], endpoint) })
  .build();

console.log(app.resolve('endpoint')); // http://localhost:8080/health
await app.close();

fromClass(dependencies, Constructor, options?) calls new Constructor(...args). fromFunction(dependencies, fn, options?) calls the function without a receiver. Their tuples accept tokens plus optional, lazy, and all references. Argument and returned Promise identity are preserved. Bind a method first if it needs its receiver, for example settings.format.bind(settings).

The optional { acquisitionMode: 'auto' | 'raw' | 'nativePromise' } selects the adapter's output stage. A class with a close method remains borrowed until explicitly wrapped with withDisposal. examples/composition.ts combines tokens, classes, functions, references, and aliases.

Declare optional and lazy dependencies ​

Dependency references work only inside the positional dependency tuples accepted by fromFunction, fromClass, and fromPlugin.

Standalone example:

ts
import { DiBag } from 'di-bag';

const portKey = Symbol('port');
const hostKey = Symbol('host');
const port = DiBag.token(portKey).of<number>();
const host = DiBag.token(hostKey).of<string>();

class Reporter {
  constructor(
    private readonly getPort: () => number,
    private readonly host: string | undefined,
  ) {}
  address() {
    return `${this.host ?? 'localhost'}:${this.getPort()}`;
  }
}

const reporter = DiBag.fromClass([DiBag.lazy(port), DiBag.optional(host)], Reporter);
const app = DiBag.createBuilder()
  .register(port, () => 8080)
  .register({ reporter })
  .build();

app.resolve('reporter').address(); // localhost:8080
await app.close();

optional(token) supplies Service | undefined only when the binding is absent. A bound service whose value is undefined is present, acquired, and owned in the ordinary way. Failures from a present optional service still propagate. An optional TypeScript function parameter alone does not make a graph dependency optional.

lazy(token) supplies () => Service and acquires the target when called. The target must exist when the graph is completed. Scoped and root targets keep their cache; a transient target creates an attempt per call. The lookup records the dependency edge at call time and retains the provider's graph, owner, cancellation context, and shutdown rules. Calling it after that owner closes throws.

References preserve exact values and Promises and add no implicit awaiting or ownership. Each wraps one genuine token. References cannot be nested or used as binding identities.

Give a dependency another lookup name ​

alias(destination, target) adds a lookup for the target's canonical service.

Standalone example:

ts
import { DiBag } from 'di-bag';

const clientKey = Symbol('client');
const client = DiBag.token(clientKey).of<{ port: number }>();

const app = DiBag.createBuilder()
  .register({ service: () => ({ port: 8080 }) })
  .alias('primary', 'service')
  .alias(client, 'primary')
  .build();

app.resolve(client) === app.resolve('service'); // true
await app.close();

Both arguments can be a string name or typed token. The destination must be new. A named target must already exist so TypeScript can infer its output. A token target may be supplied later, and completion checks it. A token destination also checks that it accepts the target output.

An alias creates no cache, attempt, or owner. It preserves the canonical target's identity, Promise, lifetime, readiness, and acquisition mode. Replacing the target changes aliases in that graph. Overriding the alias destination replaces only that destination. A shared alias borrows the parent's target and context even if the child overrides that target. Transient targets cannot be shared through an alias, and root captive checks follow alias chains.

inspect(alias).aliasTarget reports its direct target, while acquisition snapshots come from the canonical service. Use an ordinary provider when the new lookup must transform a value or add separate ownership.

Compose an ordered collection ​

Contributions let features append middleware, handlers, validators, or other ordered services without competing for one singular binding.

Standalone example:

ts
import { DiBag } from 'di-bag';

type Step = (text: string) => string;
const stepKey = Symbol('pipeline step');
const step = DiBag.token(stepKey).of<Step>();

const prefixFeature = DiBag.createBuilder()
  .register({ prefix: () => 'Hello, ' })
  .contribute(
    step,
    ({ prefix }: { prefix: string }): Step =>
      (text) =>
        prefix + text,
  )
  .buildModule([]);

const app = DiBag.createBuilder()
  .contribute(step, (): Step => (text) => text.trim())
  .installModule(prefixFeature)
  .contribute(step, (): Step => (text) => `${text}!`)
  .register({
    pipeline: DiBag.fromFunction(
      [DiBag.all(step)],
      (operations) => (text: string) =>
        operations.reduce((value, operation) => operation(value), text),
    ),
  })
  .build();

app.resolve('pipeline')('  DI  '); // Hello, DI!
app.resolveAll(step); // readonly Step[]
await app.close();

contribute(token, registration) appends one checked provider. Host and module installation order determine the result order. resolveAll(token) returns a new frozen array on every read; acquired service objects retain their identity and mutability. An empty collection is valid. Repeated providers or module installs create distinct contribution bindings; there is no deduplication.

all(token) supplies the collection to a positional adapter and does not require a singular binding. Singular register and collection contribute remain separate lookup channels. Each contribution keeps its own dependencies, lifetime, acquisition mode, attempt, and cleanup ownership. Collection reads do not await items or create an aggregate owner. A partial failure propagates the original error while accepted items remain owned until normal shutdown; a retry can reuse them.

A module contribution is installed even from a module with buildModule([]) and may use private helpers. inspectAll(token) returns ordered frozen inspection snapshots without acquiring the items. To share a computed collection with a child, share an ordinary aggregate provider such as pipeline; direct child collection reads follow the child's graph and lifetime routing.

Project services explicitly ​

Use transformService with mode: 'direct' to pass the exact source value to the transformation, or mode: 'awaited' to await the source and adopt the result into a native Promise.

Conceptual example: openConnection and makeClient are application functions, and both returned objects provide the shown close method.

ts
const connection = DiBag.withDisposal(openConnection, (value) => value.close());
const client = DiBag.withDisposal(
  DiBag.transformService(connection, {
    mode: 'awaited',
    transform: (value) => makeClient(value),
  }),
  (value) => value.close(),
);

const app = DiBag.createBuilder().register({ client }).build();
const readyClient = await app.resolve('client');
await app.close(); // client, then its source connection

transformService(registration, { mode: 'direct', transform, acquisitionMode? }) passes the source exactly as exposed. If that value is a Promise, the projector receives the Promise with its identity unchanged. The projector's exact return value is exposed and its output stage may select auto, raw, or nativePromise acquisition.

transformService(registration, { mode: 'awaited', transform }) awaits the source and projector result, always exposing a native Promise<Awaited<Result>>. Both helpers call the source once per attempt and retain dependencies and static metadata. Mapping alone adds no ownership. Projectors run without a receiver.

If projection fails, the caller receives the original error. Accepted ownership stages from that attempt are released; separately cached dependencies stay owned by their bags. Shutdown waits for pending sources and projections before releasing values they might still use. Nonsettling work can therefore keep close() pending.

Attach metadata and inspect without resolving ​

Static metadata describes a provider without acquiring it. Inspection combines that description with a copied view of current acquisition attempts.

Standalone example:

ts
import { DiBag } from 'di-bag';

const service = DiBag.withMetadata(
  ({ clock }: { clock: { now(): number } }) => ({ read: () => clock.now() }),
  { static: { 'app:owner': { team: 'platform' } } },
);
const feature = DiBag.createBuilder()
  .register({ service })
  .buildModule(['service']);
const app = DiBag.createBuilder()
  .installModule(feature.renameExport('service', 'client'))
  .register({ clock: () => ({ now: () => 42 }) })
  .build();

const before = app.inspect('client'); // no factory runs
before.registrationMetadata['app:owner'].team; // platform
before.acquisitions; // []
app.resolve('client').read(); // 42
app.inspect('client').acquisitions[0]?.state; // ready
await app.close();
app.inspect('client').acquisitions; // []

withMetadata(registration, { static: metadata }) preserves the provider's output, dependencies, acquisition mode, and ownership. It copies and freezes all own string and symbol entries, including non-enumerable keys; payload objects keep their identity. Repeated metadata wrappers may add keys but cannot collide.

Use withMetadata(registration, { dynamic: { mode: 'direct', describe } }) when the metadata is known only after a value is produced. describe synchronously receives the exact source output, including a raw or native Promise itself, and its record becomes the next typed acquisition frame. The provider still exposes the exact source value with the same acquisition mode. Use withMetadata(registration, { dynamic: { mode: 'awaited', describe } }) to await the source, describe its fulfilled value, and expose a native Promise<Awaited<SourceOutput>>. Both callbacks must synchronously return a plain object record with the current realm's Object.prototype or null as its prototype. Arrays, functions, class instances, dates, Promises, and thenable records are rejected.

inspect(nameOrToken) returns a frozen snapshot with bindingId, label, registrationMetadata, and acquisitions. Each acquisition has acquisitionId, state, and an ordered acquisitionMetadata tuple of presence records. It contains no service values or live mutable runtime collections. A snapshot does not update after it is returned. Failed attempts are evicted rather than retained as history. After close, static metadata remains available and acquisition lists are empty.

inspectAll(token) does the same for each contribution in declaration order. Aliases expose their direct target description and canonical acquisition state. Metadata wrappers add frames as described under acquisition values and metadata.

inspectGraph() describes the whole bag at once: every binding with its public keys, label, lifetime, acquisition mode, ownership, typed-token dependencies, static metadata, and current attempts; every contribution group; and the consumer-to-dependency edges observed during acquisition so far. Private bindings from installed modules appear with an empty key list. Nothing is acquired, and the snapshot is frozen. Named dependencies read from a factory's object parameter are unknown until that factory runs, so the edge list grows as services are acquired; the static graph tool reports declared edges from source.

ts
const graph = app.inspectGraph();
graph.bindings.map(binding => [binding.keys, binding.lifetime]);
graph.observedEdges; // [] before any resolve

Observe lifecycle transitions ​

Observers send telemetry without joining the service or cleanup control flow.

Standalone example:

ts
import { DiBag } from 'di-bag';

const observed = DiBag.withConfiguration({
  observers: [
    {
      onEvent(event) {
        console.log(event.kind, event.scopeId);
      },
      onError({ event, error }) {
        console.error('Telemetry failed', event.kind, error);
      },
    },
  ],
});

const app = observed
  .createBuilder()
  .register({ answer: () => 42 })
  .build();
app.resolve('answer');
await app.close();

Both callbacks are required for every observer. withConfiguration({ observers }) returns a new DiBagApi and appends that array in order after inherited observers. An omitted runtime preserves the current native-Promise classifier. Existing facades, builders, and bags keep the configuration with which they were created.

Events cover scope opening/closing, acquisition start/readiness/failure, and cleanup start/failure/completion. They carry stable scope and attempt identities, canonical binding information, lifetime, registrationMetadata, and copied acquisitionMetadata where applicable. Shared attempts report their actual owner. A raw Promise is ready as a value; a native stage reports readiness after settlement.

Callbacks run in emission and registration order on a microtask queue, outside synchronous factory execution. Callback throws or rejections go to that observer's onError; errors in onError are consumed. Observer work never gates resolution, startup, or close. If delivery completion matters, the application must maintain and await its own barrier. A synchronous burst queues events until the microtask drain; pending callback results do not slow the producer or prevent later callbacks from starting. An indefinitely slower consumer therefore has no finite lossless memory bound. Keep callbacks small and control production or explicitly batch work in the application. Externally pending callback work and its associated failure-reporting state remain live until that work settles. examples/observers.ts shows that pattern.

Admit an application-selected plugin ​

fromPlugin creates a checked boundary for an unknown descriptor selected by application code. DI Bag does not load a path or choose an export.

Standalone example:

ts
import { DiBag } from 'di-bag';

interface Handler {
  handle(text: string): string;
}
const handlerKey = Symbol('handler');
const handler = DiBag.token(handlerKey).of<Handler>();

const selected: unknown = {
  apiVersion: 1,
  create: () => ({ handle: (text: string) => text.toUpperCase() }),
};

const provider = DiBag.fromPlugin([], selected, {
  acquisitionMode: 'raw',
  validate: (value: unknown): value is Handler =>
    typeof value === 'object' &&
    value !== null &&
    'handle' in value &&
    typeof value.handle === 'function',
});

const feature = DiBag.createBuilder()
  .register(handler, provider)
  .buildModule([handler]);
const app = DiBag.createBuilder().installModule(feature).build();
console.log(app.resolve(handler).handle('hello')); // HELLO
await app.close();

A descriptor requires own apiVersion: 1 and callable create properties; an own dispose is optional and must be callable. fromPlugin(dependencies, descriptor, options) requires both validate and acquisitionMode: 'raw' | 'nativePromise'. Dependencies may be tokens or required/optional/lazy/all references and arrive in tuple order. The tuple, callbacks, and descriptor fields are captured immediately.

Raw mode validates the exact returned value synchronously. Native mode requires a genuine native source Promise and exposes one stable Promise whose fulfilled value is validated. The predicate must synchronously return exactly true. Descriptor failures use DiBagPluginValidationError phase 'descriptor'; invalid output uses phase 'output'.

When a descriptor has a disposer, the source value becomes owned before output validation. A failed validator therefore still releases the original acquired value during rollback or close. Validation checks this boundary once; it does not sandbox plugin code or continuously validate a mutable service.

Portable mode ​

On Node, Bun, and Deno, di-bag classifies native Promises with the host's util.types.isPromise, loaded through process.getBuiltinModule at the first build() that needs it; di-bag/node configures the same classifier at import. Browsers, workers, and other hosts have no process.getBuiltinModule, so there build() throws DI_BAG_CLASSIFIER_REQUIRED, naming every registration that still uses automatic acquisition.

The portable style says on each registration whether its factory is synchronous or asynchronous, so no classifier is needed anywhere:

Standalone example:

ts
import { DiBag } from 'di-bag';

type Config = { readonly url: string };
type Catalog = { names(): Promise<string[]>; close(): Promise<void> };

const app = DiBag.createBuilder()
  .register({
    config: DiBag.fromSyncFactory((): Config => ({ url: 'memory:' })),
    catalog: DiBag.withDisposal(
      DiBag.fromAsyncFactory(async ({ config }: { config: Config }): Promise<Catalog> => ({
        names: async () => [config.url],
        close: async () => {},
      })),
      catalog => catalog.close(),
    ),
    handler: DiBag.fromSyncFactory(({ catalog }: { catalog: Promise<Catalog> }) => ({
      list: async () => (await catalog).names(),
    })),
  })
  .build();

console.log(await app.resolve('handler').list()); // ['memory:']
await app.close();

fromSyncFactory(create) is a raw stage: the exact return value is the service and then is never read. The compiler rejects an async function, a Promise-returning function, a union with a Promise member, or a thenable such as a query builder with fromSyncFactory output must not be a Promise or thenable. fromAsyncFactory(create) is a nativePromise stage: the service is the returned Promise, consumers declare and await it, and withDisposal receives the fulfilled value. The compiler rejects a non-Promise output, a union, or a PromiseLike with fromAsyncFactory requires a Promise output. Both accept { context: 'acquisition' } like fromFactory. Ownership, lifetimes, metadata, modules, scopes, and forks are unchanged: the helpers only fix the mode that fromFactory(create, { acquisitionMode }) spells out.

Thenables and foreign Promises: fromAsyncFactory uses the engine's own check, Promise.prototype.then called on the value, so a Promise from another realm or a Promise subclass is observed like any native Promise, and an own then override on the instance is never called. A value that is not a native Promise, reachable only through a cast because the type is rejected, fails that acquisition with the engine's TypeError and never has its then called. A Promise object that is itself the service, or a thenable that is the service, keeps DiBag.fromFactory(create, { acquisitionMode: 'raw' }).

Everything reachable must be explicit: private module services, overrides in fork and createScope, and every direct transformService, fromFunction, and fromClass, which take acquisitionMode as an option. Graph completion checks the whole graph before any factory runs and lists what is still automatic. The other portable strategy is a trusted application-local classifier:

Conceptual snippet: trustedHostPredicate is supplied by the application.

ts
import { DiBag as CoreDiBag } from 'di-bag';

const DiBag = CoreDiBag.withConfiguration({
  runtime: {
    isNativePromise: trustedHostPredicate,
  },
});

It must identify native Promises without using a structural thenable test or a plain instanceof test. withConfiguration() returns a new facade; it does not mutate global state. Its context follows builders, bags, scopes, and forks.

The stage rules are precise:

  • raw exposes the exact return value without reading then.
  • nativePromise requires a Promise-shaped TypeScript output, observes native fulfillment, and still exposes the exact source Promise.
  • fromSyncFactory and fromAsyncFactory are fromFactory with raw and nativePromise fixed, plus a compile-time check that the output agrees.
  • auto asks the configured predicate, or the host's util.types.isPromise when none is configured and the host exposes process.getBuiltinModule.
  • fromFactory, fromFunction, and fromClass select their result stage's acquisitionMode; omission defaults to auto.
  • transformService in direct mode independently selects its output acquisition mode, defaulting to auto. It can use raw to own a returned Promise itself.
  • transformService and dynamic withMetadata in awaited mode introduce a native Promise stage. They expose a Promise even for a synchronous source.
  • withDisposal, withLifetime, static-only metadata, direct dynamic metadata, token binding, aliases, and module installation retain source acquisition modes.

Automatic or native observation tracks fulfillment for ownership and readiness without replacing the exposed Promise. A raw Promise is an immediate value. See the server guide's Deno section for a full portable-host composition.

Represent acquisition values and metadata natively ​

Use ordinary factory return values to carry a payload and facts learned while producing it. Then use transformService to project the part consumers need. Presence<T> preserves the difference between an absent value and a present value whose payload is undefined.

Standalone example:

ts
import { DiBag, type Presence } from 'di-bag';

type Located<T> = {
  readonly value: Presence<T>;
  readonly origin: string;
};

const located = DiBag.withMetadata(
  (): Located<number | undefined> => ({
    value: { present: true, value: undefined },
    origin: 'environment',
  }),
  { dynamic: { mode: 'direct', describe: (result) => ({ origin: result.origin }) } },
);
const value = DiBag.transformService(located, {
  mode: 'direct',
  transform: (result) => result.value,
});

const app = DiBag.createBuilder().register({ value }).build();
const acquired = app.resolve('value');
console.log(acquired.present); // true
console.log(acquired.present && acquired.value); // undefined
console.log(app.inspect('value').acquisitions[0]?.acquisitionMetadata[0]);
await app.close();

The direct metadata mode calls its synchronous describe callback with the exact source output and preserves that output's identity and acquisition policy. This matters when a raw stage intentionally exposes a Promise as an ordinary value: the callback and consumer see the same Promise object.

The awaited metadata mode awaits the source before calling describe and always exposes a native Promise of the source's awaited value:

ts
const located = DiBag.withMetadata(async () => ({ value: 42, origin: 'remote-config' }), {
  dynamic: { mode: 'awaited', describe: (result) => ({ origin: result.origin }) },
});
const value = DiBag.transformService(located, {
  mode: 'awaited',
  transform: (result) => result.value,
});

Each metadata wrapper reserves an absent frame before its source runs. The direct mode fills that frame as soon as the source returns and describe succeeds, even when the source output is a still-pending Promise. The awaited mode leaves its frame absent until the source fulfills and describe succeeds. Frames from repeated wrappers remain in declaration order. Inspection itself never starts an acquisition.

Each captured metadata frame is a shallow, frozen copy of the returned record. Nested objects and service payloads keep their identities and are not deep-frozen. Invalid records, asynchronous metadata callbacks, and callback errors fail the acquisition through the wrapper's selected mode.

Metadata wrappers and projections add no ownership. Existing ownership from withDisposal is retained through them; add a new withDisposal only when the bag should own the projected value too. Ordinary factories remain borrowed even when their values have close() or dispose() methods. Run examples/provider-metadata.ts for sync and async acquisition metadata, present undefined, projection, and cleanup.

Exported TypeScript types ​

Prefer inference for builders, bags, providers, and modules. When a value crosses a source-file boundary, typeof and ReturnType preserve private module constraints, token contracts, lifetime rules, ownership stages, and inspection metadata better than a shorter annotation.

Conceptual type-extraction snippet: these names refer to declarations in the typed-token examples above.

ts
import type { ProviderOutput, TokenService } from 'di-bag';

type Clock = TokenService<typeof clock>;
type Stamp = ProviderOutput<typeof stamp>;
type Application = typeof app;

TokenKey, TokenService, ProviderOutput, ProviderNamedDependencies, ProviderRegistrationMetadata, ProviderAcquiredValue, ModuleExportedServices, ModuleRequiredServices, and the remaining public types are catalogued in the API reference.

Boundaries ​

DI Bag checks the declared graph and manages explicitly described ownership. Its runtime checks do not restore guarantees erased by casts, unchecked JavaScript, or an inaccurate plugin validator. It does not add framework-specific request hooks, transaction rollback, dynamic module loading, decorators, or reflection metadata. Connect those application lifecycles explicitly; the server integration guide provides concrete Node HTTP, Express, Fastify, Bun, Deno, background-job, and shutdown patterns.

Ordinary services. Checked composition. Explicit ownership.