@machine202502/di
v1.0.0
Published
Type-driven dependency injection: abstract containers, replaceable implementations and disposable resources
Maintainers
Readme
@machine202502/di
Dependency injection driven by types. Containers declare what they need, the library builds them in dependency order, hands every factory its dependencies already built, and releases everything in reverse when you are done.
No decorators, no reflection, no global registry, no string keys: a container is a value you import, and TypeScript infers the data it produces.
Install
npm install @machine202502/diESM only, Node 20 or newer.
Public imports:
import {
abstract,
container,
resolve,
OnDispose,
OnEvent,
} from "@machine202502/di";
import { openScope, scoped, value } from "@machine202502/di/scope";OnDispose is the release protocol key; OnEvent is the broadcast key for
send. Everything else from the package root or ./scope is internal and not
part of the supported surface.
The typings mention the standard Symbol.asyncDispose, so esnext.disposable in
lib is needed to write await using — and also if you compile with
skipLibCheck: false, which is not the default tsc --init gives you. Plain
dispose() asks for nothing extra.
Quick start
import { container, resolve, OnDispose } from "@machine202502/di";
const config = container({
name: "config",
dependencies: {},
factory: () => ({ url: "postgres://localhost/shop" }),
});
const database = container({
name: "database",
dependencies: { config },
factory: async ({ config }) => ({
query: (sql: string) => [sql],
[OnDispose]: () => close(config.url),
}),
});
const resolved = await resolve(database);
resolved.data.query("select 1");
await resolved.dispose();API
container({ dependencies, factory, name? })
Declares a concrete container and returns its description. dependencies is a
record of other containers; factory receives their data under the same keys and
returns the data of this container, synchronously or as a promise. name is used
in error messages only.
The returned value is the identity of the container: two calls with identical arguments are two different containers.
abstract<Data>(name?)
Declares a container that has a data type but no factory. Nothing resolves until an implementation is supplied, which lets a graph depend on a contract and pick the implementation later.
abstractContainer.implement(container)
Binds a concrete container to an abstract one and returns an implementation.
description.replace(container)
Same thing for a concrete container: whoever asks for description gets
container instead. Useful for swapping a real dependency for a fake one without
touching the graph.
An implementation may itself be replaced; chains are collapsed before the build,
so a.replace(b) together with b.replace(c) resolves a to c.
resolve(container, { implementations?, disposeTimeout? })
Builds the graph and returns a ResolvedContainer. Implementations can be passed
here, or used inline as a dependency; a passed one wins over an inline one.
disposeTimeout is how many milliseconds one release may take before this
resolve stops waiting for it. Unset means waiting forever, which is what a stuck
handler does to a shutdown, so a server should set it. Giving up only stops the
waiting: the handler keeps running, the container is reported as failed with
di:dispose:to, and everything underneath it is released as usual.
Every resolve is independent: it builds its own instances and owns them.
ResolvedContainer
data— the data of the container you resolved.getNode(container)— the data of any container in this resolve, addressed by the container itself. Ask for a concrete container, not an abstract one.send(name, ...args)— broadcasts to every owned node that carriesOnEvent. Application-defined steps that must not follow dispose order (stop listening before scopes drain, and so on). Borrowed parent nodes are not notified.dispose()— waits for nested scopes opened against this resolve (refusing newopenScopemeanwhile), then releases every owned container. Idempotent. Nodes borrowed by a scope from a parent resolve are not released by the scope.[Symbol.asyncDispose]()— the same thing under the language protocol, so a resolve can be scoped:
await using resolved = await resolve(server);OnDispose
A symbol a factory can put on its data to say what to release:
factory: () => ({
query,
[OnDispose]: async () => pool.end(),
});The handler is awaited, and whatever it returns is ignored. Data without this key
is left alone, and the Releasable interface describes data with it if you need
to type that explicitly.
The standard Symbol.asyncDispose and Symbol.dispose are honoured as well, so
anything that already follows the language protocol is released without a
wrapper. When data carries several of these keys, OnDispose wins, then the
async standard one, then the sync one. That also applies to data passed through
value(...): if the value itself is disposable (a Node stream or
IncomingMessage, for example), disposing the resolve will dispose it.
OnEvent
A symbol a factory can put on its data to react to send:
factory: ({ socket }) => ({
url,
[OnEvent]: (name) => {
if (name === "stop-listening") {
return closeListen(socket);
}
},
[OnDispose]: () => pool.end(),
});Names and arguments are defined by the application. Handlers of every owned node
run at once; failures are reported together as an AggregateError. Use this when
shutdown must not be a single reverse-graph walk — for example stop accepting
connections first, let request scopes finish, then dispose() the process
resolve so the database closes last.
Scopes
Import from @machine202502/di/scope. A scope is a nested resolve: process
services live on a parent resolve, request-only nodes are declared with
scoped() and built by openScope.
scoped({ dependencies, factory, name? })— likecontainer, but a plainresolvethat reaches it fails withdi:resolve:sc.value(data, name?)— a container that yields a ready value; used to bind request-time input to an abstract. Pass a plain object, not a NodeIncomingMessage/ stream: those carrySymbol.asyncDispose, and a failed or finished scope would close the socket while aborting or disposing.openScope(root, { parent, implementations?, disposeTimeout? })— builds the scoped graph againstparent. Nodes already present onparentare borrowed and not owned; only what this call builds is released by itsdispose().
import { abstract, container, resolve } from "@machine202502/di";
import { openScope, scoped, value } from "@machine202502/di/scope";
const database = container({
name: "database",
dependencies: {},
factory: () => createDb(),
});
// Bag, not the raw IncomingMessage — see value() above.
const httpRequest = abstract<{ request: Request }>("http-request");
const handler = scoped({
name: "handler",
dependencies: { database, httpRequest },
factory: ({ database, httpRequest }) =>
makeHandler(database, httpRequest.request),
});
const app = await resolve(database);
await using scope = await openScope(handler, {
parent: app,
implementations: [httpRequest.implement(value({ request: currentRequest }))],
});Shutdown
Nothing is hooked up for you, because when to stop is the application's call. A
service usually needs three phases that dispose() alone cannot invent: stop
accepting work, finish in-flight scopes, then release process resources.
const resolved = await resolve(server, { disposeTimeout: 10_000 });
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, async () => {
try {
// 1. Application-defined: close the listen socket (OnEvent on the server).
await resolved.send("stop-listening");
// 2–3. Wait for open scopes, then release the graph (database last).
await resolved.dispose();
} catch (error: unknown) {
console.error(error);
}
process.exit(0);
});
}dispose() refuses new openScope calls while it waits for nested scopes that
are already open (disposeTimeout bounds that wait as well as each release).
Once dispose has started, openScope stays refused even if waiting for scopes
timed out (di:dispose:ct) — retry dispose() after the scopes finish, do not
open new ones. Stopping the HTTP listener so new requests never arrive is still
your job — typically via send, not via graph order.
Behaviour
- One instance per resolve. A container reached through several paths is built once, and everyone gets the same data.
- Build order. A factory runs only once all of its dependencies are built, so it always sees complete data. Independent branches of the graph are built at the same time, so startup costs the longest path rather than the sum.
- Release order.
dispose()goes the other way: nobody is released before those who depend on it, and independent branches again go at once. Every container is released even if another handler threw, and the failures are reported together as anAggregateError. Nested scopes are waited for before any owned node is released; new scopes are refused once dispose has started. - Dependencies are frozen when a container is created, so the graph cannot be rewritten afterward.
- Failed builds do not leak. If a factory throws,
resolvewaits for the factories still in flight, releases everything that got built, and only then rejects — the caller never receives a handle it could clean up itself. If several factories failed, they are reported together. - Unused implementations are an error, not a silent no-op: passing an implementation the graph never asks for is a wiring mistake.
- Two copies of this package coexist. Containers are recognized by a globally
registered key rather than by
instanceof, so a container built by one copy works in another. - Scoped containers stay out of process resolves. A description created by
scoped()fails under plainresolve(di:resolve:sc);openScopebuilds it and borrows matching nodes from its parent without taking ownership. A plaincontainer()missing from the parent fails withdi:resolve:npinstead of silently building a second copy.
Errors
Every error carries a stable code, so messages can change without breaking your handling.
| Code | Meaning |
| ---------------------- | ------------------------------------------------------------------- |
| di:container:if | factory is not a function |
| di:container:id | dependencies is not an object |
| di:container:ic | a dependency is not a container |
| di:implementation:ic | an implementation is not built by container() |
| di:resolve:ic | a value passed where a container was expected |
| di:resolve:iae | two implementations for the same container |
| di:resolve:icc | implementations replace each other in a cycle |
| di:resolve:ccnf | an abstract container has no implementation |
| di:resolve:dc | dependency cycle, with the path in the message |
| di:resolve:ui | a passed implementation is never used |
| di:resolve:nnf | getNode asked for a container outside this resolve |
| di:resolve:ad | a resolved container is used after dispose() |
| di:resolve:sd | openScope after the parent has started disposing |
| di:resolve:sc | a scoped container was reached by a plain resolve |
| di:resolve:np | a nested resolve needs a process container the parent does not have |
| di:resolve:mf | several factories failed in one build |
| di:resolve:rf | a build failed and releasing what it built failed too |
| di:dispose:df | one or more containers failed to release |
| di:dispose:to | a release did not finish within disposeTimeout |
| di:dispose:ct | nested scopes did not finish within disposeTimeout |
| di:event:ef | one or more OnEvent handlers failed during send |
Examples
examples/main.ts is a working application: an Express API over SQLite.
Resolving it opens the database, applies the schema, wires the routes and starts
listening; one dispose() closes the socket and then the database. The second
half resolves the same graph with a frozen clock and no HTTP layer at all, which
is what a test would do.
examples/scope.ts is the same stack with per-request scopes: process services
stay open, each HTTP hit opens a scope (user, connection, transaction, notes),
and handlers call notes.list() / notes.add(text) with no user id threaded
through. Parallel alice/bob requests and a rolled-back failure are exercised
over the live server.
npm run example:main
npm run example:scopeLicense
0BSD
