@zudojs/container
v1.3.0
Published
Token-based dependency injection container for managing application dependencies and service lifetimes.
Maintainers
Readme
@zudojs/container
Token-based dependency injection container for managing application dependencies and service lifetimes.
Documentation: zudojs.oyinlola.site/docs/packages-container · For AI agents: Markdown version, llms.txt
Installation
npm install @zudojs/containerQuick Start
import {
createContainer,
createToken,
ContainerScope,
} from "@zudojs/container";
const container = createContainer();
const LOGGER = createToken<Logger>("logger");
// Registration takes a provider object plus options:
container.register(
LOGGER,
{ useFactory: () => createLogger({ name: "app" }) },
{ scope: ContainerScope.SINGLETON },
);
const logger = container.resolve(LOGGER);
const [log, db] = container.resolveMany([LOGGER, DB]); // typed [Logger, Db]Convenience helpers wrap the provider-object form:
container.registerValue("config", { port: 3000 }); // always SINGLETON
container.registerClass(DB, PostgresDb, { scope: ContainerScope.SINGLETON });
container.registerFactory(API, (db) => new Api(db), [DB]); // deps via inject list
container.registerExisting("db-alias", DB); // alias to another tokenEvery register* call except registerValue defaults to
ContainerScope.TRANSIENT: the class or factory runs again on every
resolve(). Pass { scope: ContainerScope.SINGLETON } (the fourth argument
of registerFactory) for a pool, a client or anything else that must be
shared.
A factory's parameters are typed from its inject list, in order: with
DB = createToken<Db>("Db"), (db) => new Api(db) receives db: Db, and a
factory whose parameters do not match the tokens is a compile error. Class
tokens type the same way; plain string and symbol tokens give unknown.
factoryProvider(factory, inject) and provideFactory(token, factory,
inject) infer the same way.
registerClass(token, Class, { inject }), classProvider(Class, inject) and
provideClass(token, Class, inject) check the constructor the same way:
inject: [CLOCK, DB] against constructor(db: Db, clock: Clock) is a compile
error, and so is omitting inject for a constructor with required
parameters. Untyped string/symbol tokens check nothing for classes, a
non-tuple ProviderToken[] built at runtime accepts any constructor, and
defaulted parameters beyond the list are fine.
container.registerFactory(
REPORT,
(db, clock) => new Report(db, clock), // db: Db, clock: Clock
[DB, Clock],
);Tokens
Tokens can be strings, symbols, classes, or InjectionTokens from
createToken<T>() / createGlobalToken<T>().
- Plain string/symbol tokens are untyped casts — the compiler cannot
verify that what you registered under
"logger"matches the type you request later. PreferInjectionTokens or class tokens. createGlobalToken(key)usesSymbol.for(key): any other call with the same key anywhere in the process yields the same token. That is by design for cross-package sharing; namespace your keys (e.g."myapp:db").- Error messages and default registration names show a symbol token by its
description:
createToken<Db>("Database")appears asDatabase, notSymbol(Database)(describeToken(token)returns the same string).
Lifetimes (scope option)
The default scope is ContainerScope.TRANSIENT.
| Scope | Cache | Disposal |
| ----------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| SINGLETON | One instance in the container's root cache — the same instance is returned at the root and inside every scope | Disposed by container.dispose() (or when its registration is replaced/removed) |
| SCOPED | One instance per scope created with container.createScope() (nested scopes see their ancestors' instances) | Disposed by scope.dispose() |
| TRANSIENT | Never cached — a new instance per resolution | Never tracked — the caller owns disposal |
Resolving a SCOPED token at the container root throws a
ScopedResolutionError ("Scoped token X cannot be resolved outside a
scope"). A SINGLETON whose dependency chain contains a SCOPED
registration throws a CaptiveDependencyError — a longer-lived consumer must
not capture a shorter-lived dependency.
registerValue() always registers as SINGLETON, overriding any
options.scope you pass — a value provider can only ever return the one
instance you gave it. The container did not create that value, so it
never disposes it: a pool or logger shared between containers, or owned
by the host, stays open when a container is disposed. Register a factory
instead when the container should own the instance.
A useExisting alias never becomes a second owner of its target's instance:
a SINGLETON or SCOPED target is disposed exactly once by whoever created
it, and a SCOPED alias of a SINGLETON leaves the singleton alive when the
scope is disposed. Only a TRANSIENT target captured by a cached alias is
tracked (and disposed) through the alias.
Tracking covers every SINGLETON/SCOPED instance the container
creates — including ones created transitively as dependencies of another
resolution — not just the value returned by the resolve() call.
Async factories are not supported for cached lifetimes
The container is synchronous. A factory registered as SINGLETON or
SCOPED that returns a Promise throws an AsyncProviderError — otherwise
the Promise itself would be cached as "the instance" and the eventual value
would never be disposal-tracked. Await the resource first and register the
result (registerValue), or make the registration TRANSIENT and let
callers await it themselves. There is no resolveAsync.
Scopes
const scope = container.createScope({ name: "request-42" });
const service = scope.resolve(REQUEST_SERVICE); // SCOPED: cached per scope
await scope.dispose(); // disposes SCOPED instances only- Singletons resolved through a scope come from (and stay owned by) the
container;
scope.dispose()never touches them. scope.createScope()creates a nested child scope:- the child sees
SCOPEDinstances already created by its ancestors (lookups chain upward), while instances it creates itself are private to the child and disposed with it; - disposing a scope disposes its children first (most recent first);
child.getParent()returns the parent scope (getParent()on a top-level scope returns the container);getContainer()always returns the container.
- the child sees
- The container keeps track of its live scopes:
container.dispose()disposes them (and their children) first, and resolving from any scope after it or one of its ancestors is disposed throws.
Constructor and factory injection
Providers declare dependencies with an inject token list:
class Api {
constructor(
private db: Db,
private logger: Logger,
) {}
}
container.registerClass(API, Api, { inject: [DB, LOGGER] });
// or
container.register(API, { useClass: Api, inject: [DB, LOGGER] });
container.register(API, {
useFactory: (db, log) => new Api(db, log),
inject: [DB, LOGGER],
});Classes without an inject list are constructed with zero arguments, so
only a constructor that declares no parameters may omit it (parameters with
a default value do not count). In TypeScript, registering such a class
without inject is a compile error; at runtime (JavaScript callers, casts)
registering is allowed but resolving throws a ProviderResolutionError
naming the class and the inject: [...] fix, instead of building it with
undefined dependencies. There is no reflection/decorator magic — dependencies are
exactly the tokens you list, resolved in order.
Duplicate registrations
Registering a token twice throws DuplicateRegistrationError unless the
container was created with { registry: { allowDuplicates: true } }, in
which case the later registration wins. Use container.replace() to
intentionally swap a registration.
Replacing or removing a registration evicts and disposes its cached
singleton — and evicts any cached useExisting alias of it — so the next
resolve() of either token uses the new provider. Eviction cascades: every
cached singleton that was built on the replaced token (directly or through
a TRANSIENT in between) is evicted and disposed too, so consumers are
rebuilt against the new instance instead of keeping the old, disposed one.
Live scopes drop (and dispose) their cached SCOPED copies of the replaced
token and of its consumers.
DuplicateRegistrationError, CircularDependencyError,
RegistrationNotFoundError and ProviderResolutionError are defined in
@zudojs/errors and re-exported from this package, so instanceof checks
need only one import.
Resolution options
const container = createContainer({
resolution: {
autoRegisterClasses: true, // default
detectCircularDependencies: true, // default
maxResolutionDepth: 100, // default
},
});autoRegisterClasses— resolving an unregistered class token registers it on the fly asTRANSIENT. When registrations are frozen the class is instantiated ephemerally without being registered. Set tofalseto require explicit registration (canResolve/resolveOptionalrespect this). Only a class whose constructor declares no required parameters (Class.length === 0) is auto-registered: auto-registration has no inject list, soresolve(NeedsDep)forconstructor(dep: Dep)throwsRegistrationNotFoundErrornaming the class and telling you to register it with aninjectlist, instead of building it withdep = undefined. Parameters with defaults and rest parameters do not count:constructor(deps: Deps = {})haslength === 0, so it is auto-registered and built with no arguments — the default applies and nothing is injected. Register such a class with aninjectlist when the container should supply those values; the container cannot tell "optional" from "please inject" at runtime.detectCircularDependencies— circular chains throwCircularDependencyErrorwith the full chain. When disabled,maxResolutionDepthstill stops runaway recursion.maxResolutionDepth— chains deeper than this throwMaxResolutionDepthErrornaming the token and depth.
Resolution failures are wrapped in DependencyResolutionError (a subclass of
ProviderResolutionError from @zudojs/errors) carrying the failing
token, the resolution chain, and the original error as cause. The
message includes all three:
Failed to resolve db: connect ECONNREFUSED (chain: api -> repo -> db).
resolveOptional(token) returns undefined when the token has no
registration; other failures (broken factories, missing dependencies of a
registered token, captive dependencies) still throw.
Start semantics
container.start() is optional — the first resolve()/createScope()
auto-starts the container. With freezeRegistrations: true, starting
(including that implicit start) freezes the registration set: register,
replace, remove, clearRegistrations, and restoreSnapshot throw from
then on.
Disposal
await container.dispose();- Disposes all live scopes, then all tracked singletons in reverse
creation order (a dependency is created before its dependents, so it is
disposed after them). Instances are disposable if they
have a
dispose()method or implementSymbol.dispose/Symbol.asyncDispose. TRANSIENTinstances are never tracked; dispose them yourself.registerValue()values are never disposed by the container.- With
autoDispose: false,dispose()releases tracked references without disposing them — disposal becomes your responsibility. - Disposal is terminal: the container is marked disposed even if some
instances fail to dispose, and every failure is reported in the thrown
AggregateError.dispose()is idempotent; any use after disposal throws. The container counts as disposed from the momentdispose()is called — aresolve()racing the disposal throws rather than creating an instance nobody will clean up — and concurrentdispose()calls all settle when the one disposal finishes. clearSingletons()evicts and disposes cached singletons without disposing the container.
Snapshots (testing)
snapshot() captures the current registrations (not instances);
restoreSnapshot(snap) wholesale-replaces the registration set with a
snapshot, evicting and disposing cached singletons from the old set. Both are
refused once registrations are frozen or the container is disposed.
const snap = container.snapshot();
container.replace(DB, { useValue: fakeDb });
// ... test ...
container.restoreSnapshot(snap);Features
- Token-based registration (string, symbol, class,
InjectionToken) - Singleton, scoped, and transient lifetimes with strict cache coherence
- Explicit dependency injection via
injectlists (classes and factories) - Circular dependency detection and resolution depth limiting
- Lifecycle tracking with deterministic reverse-order disposal
- Container snapshots for testing
Use Cases
- Managing service dependencies
- Testing with mock dependencies
- Plugin and module systems
- Application composition root
