@zudojs/container
A token-based dependency injection container: register how each service is built, and let the container create, share and clean it up for you.
OVERVIEW
When an app grows, its parts start to need each other. A request handler needs a database, the database needs a config object, the config needs a logger. Wiring all of that by hand means every file has to know how to build everything it uses.
Dependency injection (DI) turns that around. Each part says what it needs, and one central object, the container, builds it and hands it over. @zudojs/container is that object.
You describe each dependency once, say how long it should live, and ask for it wherever you need it. The container handles creation order, caching and cleanup.
When you need it
- Several services depend on each other and you want one place that builds them.
- You want one shared instance of something (a DB pool) and fresh instances of other things.
- You need per-request objects that are cleaned up when the request ends.
- You want to swap a real service for a fake one in tests without editing the code under test.
When you don't
- A script or tiny app with two or three objects. Plain
newis clearer. - Your dependencies are all created asynchronously and you want the container to await them. It is synchronous by design.
- You want decorators or reflection to guess dependencies. This container only injects what you list explicitly.
INSTALLATION
Install the package. @zudojs/errors is pulled in automatically, but add it explicitly if you plan to import error classes from it in your own code.
Requires Node.js 24+ and ES modules. Everything is imported from the one entry point; subpaths like @zudojs/container/scope do not exist.
QUICK START
This example registers a logger and a greeter that needs the logger, then asks the container for the greeter. Save it as app.ts and run it with npx tsx app.ts.
What you should see:
registerClass says "when someone asks for GREETER, build a Greeter and pass it whatever LOGGER resolves to". resolve is the ask. You never write new Greeter(...) yourself.
container.start(). The first resolve() or createScope() starts the container for you.
TOKENS
A token is just a named key. You register something under a token, and later you ask the container for that token and it hands you the thing. The container accepts three kinds of key: a string, a symbol, or a class.
createToken<T>(description) is the recommended way to make one. It wraps a fresh symbol so two tokens can never clash, and it carries the type T, so resolve() returns a properly typed value.
This example shows the three ways to name a dependency and what each one gives you back.
createToken("db") called twice gives two different tokens, even with the same description. Create each token once, export it from a shared file, and import it everywhere else. Otherwise you register under one token and ask for another, and get RegistrationNotFoundError (or, when the unregistered token is a dependency, a DependencyResolutionError with it as cause). Both messages name the token by its description, db, whichever copy you used.
PROVIDERS: FOUR WAYS TO REGISTER
A provider is the recipe that tells the container how to produce a value for a token. There are four kinds, and the container has a shortcut method for each one.
| Method | What it does | Notes |
|---|---|---|
registerValue(token, value) | Hands back the exact object you gave it. | Always SINGLETON, whatever scope you pass. Never disposed by the container (the value is yours). |
registerClass(token, Class, { inject }) | Calls new Class(...deps). | inject lists the tokens to pass, in constructor order. Omit it only for a constructor with no parameters, or only defaulted ones; since v1.2.1 resolving a class that needs arguments but has no inject throws ProviderResolutionError. |
registerFactory(token, fn, inject) | Calls your function and uses its return value. | Resolved inject tokens become the function's arguments. |
registerExisting(token, otherToken) | Makes token an alias for otherToken. | Both tokens resolve to the same instance. |
The important idea is inject. There is no reflection or decorator magic: the container passes exactly the tokens you list, resolved in order. This example, taken from the package tests, uses all four methods.
All four shortcuts call register(token, provider, options) underneath. You can call it directly with a provider built by factoryProvider((db) => build(db), [DB]) (typed the same way) or a plain object such as { useFactory: (db) => build(db as Db), inject: [DB] }, and options such as { scope: ContainerScope.SINGLETON }.
inject. registerFactory, factoryProvider and provideFactory infer each parameter from the matching token: a Token<T> or class token gives T, a string or symbol token gives unknown. So registerFactory(API, (db) => new Api(db), [DB]) compiles under strict mode, and a factory whose parameter types do not match its tokens, or that declares more parameters than the list supplies, is a compile error (TS2345). The types are exported as InjectedDependencies<Deps> and InjectedFactory<T, Deps>. A factory typed (...deps: unknown[]) => T and a non-tuple readonly ProviderToken[] list still compile. Before v1.2.0 every parameter was unknown and needed a cast. The run-time order is still yours to get right: tokens are passed exactly in the order you list them.
DuplicateRegistrationError. To swap a registration on purpose, call container.replace(token, provider), or create the container with { registry: { allowDuplicates: true } } so the later registration wins.
LIFETIMES: SINGLETON, SCOPED, TRANSIENT
Every registration has a scope, which is how long an instance lives before the container makes a new one. You set it with the scope option using the ContainerScope enum.
SINGLETON
One instance for the whole container. Built on first ask, then reused everywhere, including inside scopes.
SCOPED
One instance per scope. Each createScope() gets its own copy. Asking at the root throws.
TRANSIENT
A brand-new instance every single time you resolve. Never cached. This is the default.
This example counts how many times each factory runs so you can see the difference.
Two rules follow from the lifetimes, and the container enforces both with a clear error:
- A SCOPED token can only be resolved through a scope. Asking the root container throws
ScopedResolutionError("cannot be resolved outside a scope"). - A SINGLETON cannot depend on a SCOPED token. It would keep the first scope's instance forever, so the container throws
CaptiveDependencyError.
AsyncProviderError instead. Await the resource first and register the result with registerValue, or keep the registration TRANSIENT and let callers await it.
SCOPES
A scope is a temporary sub-container for one unit of work, such as one HTTP request or one background job. SCOPED instances resolved through it are shared inside that scope and thrown away when you dispose it. Singletons still come from the parent container.
Here each request gets its own RequestContext, while two asks inside the same request share one.
Scopes can nest: scope.createScope() makes a child that sees its parent's SCOPED instances but keeps its own private. Disposing a parent disposes its children first; container.dispose() disposes every live scope.
await scope.dispose() when the unit of work ends, usually in a finally block. A scope that is never disposed keeps its instances alive, and resolving from a disposed scope throws.
DISPOSAL AND CLEANUP
Some objects hold resources that must be released: database connections, file handles, timers. The container tracks every SINGLETON it creates (and every SCOPED instance a scope creates) and cleans them up when you call dispose(). Values passed to registerValue() are not tracked or disposed.
An instance is cleaned up if it has a dispose() method (sync or async) or implements Symbol.dispose / Symbol.asyncDispose. Disposal runs in reverse creation order, so a dependency is closed after the things that used it.
This example registers a connection as a singleton and watches it close when the container is disposed.
Good to know:
- TRANSIENT instances are never tracked. If a transient holds a resource, you dispose it yourself.
dispose()is final. Failures are collected into one thrownAggregateError, the container is still marked disposed, and any later use throws.clearSingletons()disposes cached singletons but keeps the container usable. Replacing or removing a registration disposes its singleton too.- With
autoDispose: false,dispose()releases references without calling anything'sdispose(). Cleanup becomes your job.
RESOLUTION GUARDS
Resolving means walking the dependency chain: to build A the container first builds everything A injects, and so on. Three things can go wrong on that walk, and the container catches each one with an error that names the chain.
A circular dependency is when A needs B and B needs A. The container detects the loop instead of running forever.
When a factory or constructor throws, the container wraps the failure in DependencyResolutionError. The original error is kept as cause, and token and chain show which link broke. The message reads like Failed to resolve b: kaboom (chain: a -> b).
Tokens made with createToken() or createGlobalToken() appear in every error message, and in chain, by their description — the same string describeToken() returns, and the default registration name. With createToken<Api>("Api") depending on createToken<Db>("Database"), a throwing database factory reads Failed to resolve Database: kaboom (chain: Api -> Database), and a missing one No registration found for token "Database". (Before v1.2.0 these read Symbol(Database).) Give each token a description you will recognise in a log.
Asking for a token that was never registered throws RegistrationNotFoundError. If "maybe missing" is a normal case, use resolveOptional() or check first with has() and canResolve().
One convenience: with the default autoRegisterClasses: true, resolving a class that was never registered registers it on the fly as TRANSIENT and builds it with no arguments. Since v1.2.0 that only happens for a class whose constructor declares no required parameters (Class.length === 0; parameters with default values do not count). Resolving an unregistered class that expects dependencies throws RegistrationNotFoundError with a message that names the class and shows how to register it with an inject list; canResolve() returns false and resolveOptional() returns undefined for it. Before v1.2.0 such a class was built silently with every parameter undefined. Set the option to false if you prefer to require explicit registration for every class.
resolveOptional() only hides "not registered". A registered token whose factory throws, or whose dependency is missing, still throws so that real bugs are not swallowed.
CONFIGURATION
Every option is optional. This call shows each one with its default value.
| Option | What it does | Notes |
|---|---|---|
autoDispose | Whether dispose() calls cleanup on tracked singletons. | Default true. With false references are released, nothing is disposed. |
allowScopes | Whether createScope() is permitted. | Default true. With false, createScope() throws. |
freezeRegistrations | Locks the registration set once the container starts. | Default false. Remember the first resolve() starts the container. |
registry.allowDuplicates | Allow registering a token twice; the later one wins. | Default false (throws DuplicateRegistrationError). |
lifecycle.failFast | Stop disposal at the first failure instead of collecting them all. | Default false. |
resolution.autoRegisterClasses | Auto-register unregistered class tokens as TRANSIENT. | Default true. |
resolution.detectCircularDependencies | Throw CircularDependencyError on loops. | Default true. When off, maxResolutionDepth still stops runaway chains. |
resolution.maxResolutionDepth | Longest allowed dependency chain. | Default 100. Must be a positive integer. |
TESTING WITH FAKES
The container makes tests easy: swap the real registration for a fake, run the code under test, then put the original back. snapshot() captures the current registrations (not the instances) and restoreSnapshot() reinstates them, disposing any singletons built in between.
This example replaces a mailer with a fake and restores it afterwards.
createContainer() in beforeEach and await container.dispose() in afterEach. Snapshots are for when building the container is expensive and you want to reuse it.
API REFERENCE
Everything below is exported from @zudojs/container unless a note says otherwise.
Functions
| Name | What it does | Notes |
|---|---|---|
createContainer(options?) | Creates a Container. | Same as new Container(options). |
createStartedContainer(options?) | Creates a container and calls start(). | Only matters with freezeRegistrations: true. |
createToken<T>(description) | Makes a unique typed token. | Backed by a fresh Symbol. |
createGlobalToken<T>(key) | Makes a typed token shared by every caller using the same key. | Backed by Symbol.for(key). Namespace keys, e.g. "myapp:db". |
describeToken(token) | Returns a readable name for a token. | Useful in logs and error messages. |
classProvider, factoryProvider, valueProvider, existingProvider | Build a frozen provider object for register(). | The register* shortcuts call these for you. The provide* variants also carry the token. |
isDisposable, isAsyncDisposable, isDisposableInstance | Check whether a value can be disposed. | Same checks the container uses when tracking. |
Container methods
| Name | What it does | Notes |
|---|---|---|
register(token, provider, options?) | Registers any provider. | options.scope plus metadata fields (name, description, module). |
registerValue, registerClass, registerFactory, registerExisting | Shortcuts for the four provider kinds. | See Providers. |
resolve(token) | Builds or fetches the value for a token. | Throws on any failure. Auto-starts the container. |
resolveOptional(token) | Like resolve, but returns undefined when not registered. | Other failures still throw. |
resolveMany([a, b]) | Resolves several tokens at once. | Returns a tuple typed per token. |
has(token) / canResolve(token) | Is it registered? / Could resolve succeed? | canResolve counts auto-registrable classes (zero required constructor parameters only). |
replace(token, provider, options?) | Swaps a registration. | Evicts and disposes the old cached singleton and every cached consumer built on it; live scopes drop their copies. |
remove(token) / clearRegistrations() | Removes one or all registrations. | Cached singletons are disposed. |
createScope(options?) | Creates a ContainerScopeContext. | options.name, options.metadata. |
snapshot() / restoreSnapshot(snap) | Save and restore the registration set. | For tests. Refused when registrations are frozen. |
clearSingletons() | Disposes and forgets cached singletons. | Async. Container stays usable. |
dispose() | Disposes live scopes, then singletons, and shuts the container. | Async, idempotent, final. |
isStarted(), isDisposed() | Lifecycle state. | start() exists but is optional. |
Classes
| Name | What it does | Notes |
|---|---|---|
Container | The container itself. | Prefer createContainer(). |
ContainerScopeContext | A scope returned by createScope(). | Has resolve, resolveMany, has, canResolve, createScope, dispose, isDisposed, getParent, getContainer. |
ContainerRegistry | Stores registrations and emits change events via subscribe(). | Used internally by Container; not exposed as a property. |
Types
| Name | What it does | Notes |
|---|---|---|
Token<T> / InjectionToken<T> | A raw key (class, symbol or string) / the wrapper createToken returns. | Both are accepted everywhere a token is expected. |
ContainerOptions | Argument to createContainer. | See Configuration. |
ContainerProvider<T> | Any provider shape register() accepts. | useValue, useClass, useFactory, useExisting. |
InjectedDependencies<Deps>, InjectedFactory<T, Deps> | The parameter tuple an inject list produces, and a factory typed from it. | New in v1.2.0. Token<T> or class tokens map to T; string and symbol tokens to unknown. |
ContainerRegistration<T> | What getRegistration() returns. | token, provider, scope, metadata, createdAt. |
DisposableLike, AsyncDisposableLike, SymbolDisposableLike | Shapes the container knows how to dispose. | Disposable / AsyncDisposable still exist but are deprecated aliases. |
Errors
| Name | When it is thrown | Notes |
|---|---|---|
ScopedResolutionError | A SCOPED token is resolved at the root. | From @zudojs/container. |
CaptiveDependencyError | A SINGLETON depends on a SCOPED token. | From @zudojs/container. |
DependencyResolutionError | A factory or constructor threw. | From @zudojs/container. Has token, chain, cause. |
MaxResolutionDepthError | The chain is longer than maxResolutionDepth. | From @zudojs/container. |
AsyncProviderError | A SINGLETON/SCOPED factory returned a Promise. | From @zudojs/container. |
ContainerDisposalError | Instances failed to dispose. | From @zudojs/container. Wrapped in the AggregateError thrown by dispose(). |
CircularDependencyError | A dependency loop was found. | From @zudojs/errors. Has chain. |
RegistrationNotFoundError | Resolving a token that is not registered. | From @zudojs/errors. |
DuplicateRegistrationError | Registering a token twice. | From @zudojs/errors. |
Constants
| Name | What it does | Notes |
|---|---|---|
ContainerScope | Enum: SINGLETON, SCOPED, TRANSIENT. | String values "singleton", "scoped", "transient". |
DEFAULT_CONTAINER_SCOPE | The scope used when none is given. | ContainerScope.TRANSIENT. |
DEFAULT_CONTAINER_NAME, DEFAULT_RESOLUTION_OPTIONS | Option defaults. | See Configuration. |
COMMON MISTAKES
- Listing
injecttokens in the wrong order. With typed tokens the compiler now catches a mismatch, but two dependencies of the same type (or string tokens, which areunknown) can still be swapped silently. Fix: keep the list in parameter order and prefer typed tokens fromcreateToken<T>(). - Calling
createToken("db")in two files. You get two unrelated tokens, andresolve()throwsRegistrationNotFoundError. Fix: create each token once in a shared module and import it. - Forgetting
injecton a class with constructor arguments.registerClass(TOKEN, Service)with noinjectstill registers, but since v1.2.1resolve(TOKEN)throwsProviderResolutionError:Cannot build class "Service" for token "service": its constructor declares 2 parameters and the registration has no inject list, so it would be built with undefined dependencies.The same applies to{ useClass },classProviderandprovideClass. Up to v1.2.0 the class was built with every parameterundefined(only auto-registration refused it). Constructors with no parameters, or only defaulted ones, are unaffected. Fix:registerClass(TOKEN, Service, { inject: [DEP_A, DEP_B] })in constructor order. - Resolving a SCOPED token from the container.
ScopedResolutionError. Fix:const scope = container.createScope()andscope.resolve(TOKEN), thenawait scope.dispose(). - Expecting the default to be singleton. The default is TRANSIENT, so a DB pool factory runs on every resolve. Fix: pass
{ scope: ContainerScope.SINGLETON }for shared things. - Returning a Promise from a singleton factory.
AsyncProviderError. Fix:const db = await connect()first, thenregisterValue(DB, db)— and close it yourself, since the container does not dispose registered values.
COMPLETE EXPORT INDEX
Every name @zudojs/container exports from its package root at v1.1.2 — 131 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 131 exports
AsyncProviderError CaptiveDependencyError CircularDependencyError Container ContainerDisposalError ContainerLifecycle ContainerRegistry ContainerResolver ContainerScopeContext DependencyResolutionError DuplicateRegistrationError MaxResolutionDepthError ProviderResolutionError RegistrationNotFoundError ScopedResolutionErrorallowsContainerScopes assertValidProvider assertValidRegistrationToken canModifyRegistrations classProvider createContainer createContainerLifecycle createGlobalToken createMutableRegistrationState createRegistration createRegistrationMap createStartedContainer createToken defineRegistration describeContainerScope describeRegistration describeToken existingProvider factoryProvider freezeRegistration getProviderToken getRegistrationProviderToken getRegistrationToken hasInjectedDependencies isAsyncDisposable isCachedScope isClassProvider isConstructorToken isContainerScope isDisposable isDisposableInstance isExistingProvider isFactoryProvider isInjectionToken isScopedRegistration isScopedScope isSingletonRegistration isSingletonScope isStringToken isSymbolDisposable isSymbolToken isTokenProvider isTransientRegistration isTransientScope isValueProvider normalizeProvider provideClass provideExisting provideFactory provideValue resolveContainerOptions resolveContainerScope shouldAutoDisposeContainer shouldCacheRegistration unwrapToken validateResolutionOptions valueProvider withRegistrationMetadata withRegistrationScopeAsyncDisposableLike ClassProvider ClassRegistration Constructor ContainerLifecycleOptions ContainerLike ContainerOptions ContainerRegistration ContainerRegistryOptions ContainerScopeOptions CreateRegistrationOptions DisposableLike ExistingProvider ExistingRegistration FactoryProvider FactoryRegistration InjectionToken MutableRegistrationState RegisterClassOptions RegistrationMetadata RegistryChangeEvent ResolutionCache ResolutionOptions ResolutionResult ResolvedContainerOptions SymbolDisposableLike TokenProvider TrackedInstance ValueProvider ValueRegistrationAsyncDisposable ContainerProvider ContainerResolutionOptions Disposable DisposableInstance Provider ProviderToken RegistrationMap RegistrationToken RegistryListener RegistryToken ResolutionPath ResolvedToken ResolvedTokens Token TokenString TokenSymbolDEFAULT_ALLOW_SCOPES DEFAULT_AUTO_DISPOSE DEFAULT_CONTAINER_NAME DEFAULT_CONTAINER_SCOPE DEFAULT_FREEZE_REGISTRATIONS DEFAULT_REGISTRATION_SCOPE DEFAULT_RESOLUTION_OPTIONSContainerLifecycleOwner ContainerScope RegistryOperation