Skip to content

DI Bag ​

TypeScript dependency composition and resource ownership for modular codebases. Built for coding agents that ship one feature at a time.

Documentation · Quickstart · Modules as units of work · Comparison · Tutorial · API reference

Why DI Bag? ​

Compose ordinary TypeScript factories into reusable features. DI Bag checks declared dependencies, keeps module internals private, and manages resource creation and disposal. Build each feature against an explicit contract, test it with replaced dependencies, and let the compiler check the composition when independently developed features come together.

Install ​

Install di-bag from npm:

sh
npm install di-bag

The API is pre-1.0 and includes breaking changes. Run the 0.4-to-0.5 codemod before upgrading, then review the changelog and the 0.5 migration guide.

The minimum supported TypeScript version is 6.0.3; enable strict in your tsconfig.json. The repository checks classic TypeScript 6.0.3 and native 7.0.2. For browsers and workers, see runtime support.

Quickstart ​

A service can be a configuration object, a database client, or a function. A factory creates a service. A container resolves its factories' declared dependencies. Services are created when needed. The container disposes owned resources when you close it.

Import from di-bag. Here, greeter needs config. Its parameter type describes that dependency, and its return value is the service it provides:

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

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

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

withServices({...}) adds factories to an immutable builder. buildContainer() checks the declared graph and creates a container. resolve('greeter') creates the greeter and its config on first use. The default scoped lifetime returns the same greeter on later resolutions in this container. Provider order does not matter.

TypeScript knows that greeter has a greet(name: string): string method. Removing the config factory makes buildContainer() a compile-time error. Changing greeting to a number also fails the type check because the greeter needs a string.

Swap a dependency for a test ​

Use createIndependentContainer() for a test with a replacement dependency. This example uses the graph from the quickstart:

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

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

const testApp = app.createIndependentContainer(['config'], {
  config: () => ({ greeting: 'Hi' }),
});

try {
  console.log(testApp.resolve('greeter').greet('Ada')); // Hi, Ada!
  console.log(app.resolve('greeter').greet('Ada')); // Hello, Ada!
} finally {
  await testApp.close();
  await app.close();
}

The replacement must satisfy the original service contract. Each independent container owns its own acquisitions and disposal. Close it separately. Factories can still return shared objects captured outside the container.

Work with async services ​

An async factory provides a promise. Declare that promise in any dependent factory and await it where you need the value:

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

const app = DiBag.createBuilder()
  .withServices({
    greeting: async () => 'Hello',
    message: async ({ greeting }: { greeting: Promise<string> }) =>
      `${await greeting}, Ada!`,
  })
  .buildContainer();

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

Within one container, repeated resolutions share the same pending promise. Synchronous factories keep returning ordinary values. See async behavior for details.

Give resources a clear owner ​

Use DiBag.providerWithDisposal to tell the container how to release a factory's result:

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

const resources = DiBag.createBuilder()
  .withServices({
    cache: DiBag.providerWithDisposal({
      provider: () => new Map<string, string>(),
      disposeService: cache => cache.clear(),
    }),
  })
  .buildContainer();

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

The same pattern works for connections, clients, and subscriptions. Disposal can be asynchronous. Dependents close before their dependencies, and resources that were never created need no disposal. Ordinary factories return borrowed values. Having a close() method alone does not transfer ownership to the container.

Child and independent containers ​

OperationWhat it createsWho closes it?
container.createChildContainer()A tracked child with its own scoped servicesClose it when its work ends. The parent closes live children.
container.createChildContainer(keys, providers)A tracked child with selected scoped or transient services replacedClose it when its work ends. The parent closes live children.
container.createIndependentContainer()A separate container with fresh instancesThe caller closes it separately.
container.createIndependentContainer(keys, providers)A separate container with selected dependencies replacedThe caller closes it separately.

An unmarked provider is scoped. Choose singleton explicitly when child containers should share it.

For request handling, checked test replacements, and loading dynamic features, see the server guide and integration recipes.

The tutorial also covers modules with private services, typed tokens, class and function adapters, optional and lazy dependencies, collections, service readiness, metadata, observers, and plugin validation.

Modules as units of work ​

A DI Bag module is the unit of work that one person or one coding agent can own: a directory with a small exported contract, private services, and its own tests. The composition is checked when the modules meet, so several modules can be developed in parallel and merged with confidence.

  • A boundary an owner can hold. buildModule({ exportedServiceKeys }) seals a feature and exports only the named services. Private services and their types stay inside, and two modules can use the same private names without collision.
  • Verification without the whole application. A module declares what its host must supply. A small check file installs the module with typed fixtures and calls verifyGraphAtCompileTime(). Tests can use createIndependentContainer(keys, providers) for checked replacements without live clients.
  • Checks at merge time. Installing every module into one builder is where independently developed work meets. A missing requirement, an incompatible replacement, or a contract that no longer matches its consumers fails at buildContainer() or verifyGraphAtCompileTime(). The di-bag-graph tool exports the declared edges and cycles for review.

Install modules with withInstalledModules([...]). If two modules require the same name for different contracts, rename each requirement before installation with withRenamedRequirement({ currentRequirementKey, newRequirementKey }). Use withRenamedExport({ currentExportKey, newExportKey }) for colliding exports.

For discovery, the directory layout is the map: one directory per module, the contract first. The modularity guide describes the recommended layout and shows separately owned features, isolated tests, and contributed tools in three runnable programs.

DI Bag's dependency graph describes how services are supplied, not what runs next. LLM harnesses and agent graphs are one application of the module pattern: model clients, tools, and context sources become modules, and the harness or graph framework owns routing, retries, persistence, and execution. The agent harness and graph guide is a complete example.

How it compares ​

DI Bag's appeal is the combination of object-parameter factories, checks across the declared graph, and explicit resource ownership. Decorator-free composition, async factories, and TypeScript support are also available in other libraries.

AlternativeReasons to choose itDI Bag's different emphasis
Manual dependency injectionDirect function calls may be all a small application needs. TypeScript checks their arguments.Adds lazy caching, graph-wide composition checks, child containers, and coordinated disposal.
AwilixFunction and class registration, inferred cradle types, lifetime options, and runtime strict checks.Checks declared factory requirements against the registrations at compile time.
InversifyJS / TSyringeToken and class-oriented containers; Inversify also offers decorator-free factory bindings and awaited async resolution.Starts with object-parameter factories and immutable builders; checks accumulated graph contracts.
Typed InjectA close alternative with compile-time dependency checks, explicit dependency tuples, child injectors, and disposal.Adds object-parameter dependencies, forward references, private module exports, and selected service readiness with rollback.
Effect Context / LayerTyped requirements, scoped resources, and composition within Effect's broader async and error model.Keeps ordinary T and Promise<T> service values and explicit container ownership.
NestJS / Angular DITheir native containers connect directly to framework components, testing tools, and lifecycles.Provides standalone composition; applications supply the framework integration.

See the comparison guide for primary sources, differences in async and disposal behavior, and the limits of these comparisons. There is no verified performance ranking against these libraries.

Runtime support ​

The package has zero runtime dependencies and one entry point: di-bag. The root import configures native Promise detection on Node, Bun, and Deno. It also bundles for browsers and workers because it has no node: imports.

In browsers and workers, buildContainer() names any factory that needs an explicit return kind. Register synchronous factories with DiBag.createProvider(factory, { factoryReturnKind: 'sync-value' }) and native Promise factories with 'native-promise'. See portable mode.

Tradeoffs and limits ​

  • Agent context is still your responsibility. DI Bag does not choose module boundaries, manage an agent's context window, or replace behavioral tests.
  • Async dependencies are explicit. A factory returning Promise<T> exposes that promise. Consumers declare and await it themselves.
  • Disposal waits for your work by default. Cancellation is cooperative; a factory or disposer that never settles keeps close() pending. Pass close({ waitTimeoutMs, abortSignal }) to stop waiting. The rejection names the disposers still running, and disposal continues in the background.
  • Type safety follows the declared graph. Casts, unchecked JavaScript, and unknown plugins need appropriate runtime checks. Dependency cycles are detected at runtime, or before running by di-bag-graph.
  • Graph types have a compiler cost. One fluent expression is bounded by the compiler's recursion budget: classic TypeScript 6.0.3 accepts about 1,000 chained calls and overflows beyond that (about 950 for a bulk map followed by individual replacements); native 7.0.2 has no such ceiling. Keep an expression to 500 calls or fewer and use bulk registration, groups, or named modules beyond that. See the compiler evidence.
  • Framework integration belongs to the application. DI Bag provides the composition and ownership primitives; the host connects request, job, or UI lifecycles. The server guide and the React guide are tested recipes for both.

Explore further ​

ResourceWhat you'll find
Complete tutorialLearn every public API through examples, from first composition to advanced ownership.
API referenceExact generated signatures, overloads, type parameters, and API inventories.
Server guideNode HTTP, Express, Fastify, Bun, and Deno: shared services, request containers, service readiness, and shutdown.
React guideBrowser applications: one app runtime at bootstrap, project runtimes owned from effects, Strict Mode, cancellation, bounded teardown, and useSyncExternalStore.
Radical modularityThe recommended module layout, separately owned features, isolated tests, and contributed tools.
Agent docsRules, module layout, and check commands for coding agents, with recipes and errors. Shipped in the package.
Agent harnesses and graphsOne worked application: model and tool modules, metadata inspection, and node tests with typed fixtures.
Static dependency graphExport every builder chain, declared edge, and cycle to JSON with di-bag-graph for merge review and CI.
Runnable examplesModules, tokens, composition, collections, plugins, observers, child containers, and provider metadata.
Integration guideTested recipes for request ownership, substitutions, and dynamic features.
Comparison with alternativesWhen DI Bag or another approach may be a better fit, with primary sources.
Development and verificationFull checks, portable runtime testing, compiler scale, and performance evidence.
Documentation mapEvery guide, the generated reference, and the contributor documents.

Working on DI Bag ​

CIRuntime dependencies: 0License: MIT

After npm ci, run the main checks with Node and Bun installed:

sh
npm run platform:pin
npm run check
npm run check:native

Run an example with bun run examples/composition.ts. The development guide covers the remaining compiler, platform, and packaging checks and their tool requirements.

License ​

MIT © Dany Fedorov

Ordinary services. Checked composition. Explicit ownership.