Type-safe dependency injection for TypeScript applications, with zero dependencies, built on standard decorators with no
reflect-metadata and no experimentalDecorators.
Overview
@codefast/di manages the services your app depends on. You describe how to build each service once, then ask a
container for it by a typed key. The container constructs that service — and everything it depends on — in the right
order.
Two calls carry the whole model: bind registers a service, and resolve returns a fully wired instance.
Use dependency injection when you want swappable implementations, scoping, and lifecycle handled for you, with the
wiring checked by the TypeScript compiler. Reach for plain new while a few objects still do the job; the library earns
its keep as the dependency graph grows.
- Typed tokens.
Token<Value>flows through everybind → resolvepath, soresolve()returns exactly the type you registered. - Standard decorators.
@injectable,inject,optional,injectAll,@postConstruct, and@preDestroydeclare dependencies explicitly — no runtime reflection. - Fluent bindings. Constants, classes, sync and async factories, aliases, named/tagged/predicate constraints, and lifecycle hooks compose in one invariant chain order.
- Scopes with validation. Choose
singleton,scoped, ortransient, and callvalidate(container)to catch captive dependencies, missing bindings and cycles before the first request. - Modules and introspection. Bundle bindings into reusable modules, and inspect a container or render its dependency graph as DOT, Mermaid, Cytoscape, or React Flow.
Installation
pnpm add @codefast/di@codefast/di runs on Node.js 22.18 or later and in Chrome and Edge 136, Firefox 136, or Safari 18.4 or later
(support policy). It requires TypeScript 7 or later, with standard decorators. Leave
experimentalDecorators off — it's off by default. A browser program also needs the
disposal types.
The package is published on 0.x and versioned on its own track: breaking changes ship as minor versions, so pin the minor version when you need stability.
Toolchain
No JavaScript engine runs decorators natively yet — the TC39 proposal went back to Stage 2.7 in May 2026 — so a decorated class always passes through a transform that lowers it. What that takes depends on the toolchain:
| Toolchain | What it needs |
|---|---|
| tsx | Nothing: it lowers standard decorators for the Node it runs on. |
| esbuild | A target below esnext, such as es2024 or node24: at its default, esnext, decorators pass through. |
| Vite 8, and Vitest on it | The Babel decorators plugin: their Oxc transform lowers only experimentalDecorators. |
| SWC 1.15.21 or later | jsc.parser.decorators: true and jsc.transform.decoratorVersion: "2023-11". |
tsc, then Node directly |
A target below ESNext, and Symbol.metadata installed before any decorated class is defined. |
For Vite and Vitest, add @babel/core, @babel/plugin-proposal-decorators and @rolldown/plugin-babel as dev
dependencies, then the decorators preset to the config — vitest.config.ts takes the same one:
// vite.config.ts
import babel, { defineRolldownBabelPreset } from "@rolldown/plugin-babel";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
babel({
presets: [
defineRolldownBabelPreset({
preset: () => ({ plugins: [["@babel/plugin-proposal-decorators", { version: "2023-11" }]] }),
// Every decorator holds an `@`, so Babel skips only files that cannot hold one.
rolldown: { filter: { code: "@" } },
}),
],
}),
],
});The filter is what keeps Babel off the files without decorators; the plugin applies it only when every preset carries
one and plugins is left empty. Keep it the plain "@": a narrower pattern can miss a legal position such as
export @injectable() class, and a missed file ships its decorators raw. Beside other presets, list this one last,
since Babel runs presets last to first.
Warning. Without the plugin the decorators pass through untouched, and nothing flags it at build time:
vite buildsucceeds, then the bundle throwsSyntaxError: Invalid or unexpected tokenas it loads — the same error Vitest reports for a test file that imports a decorated class.
tsc leaves decorators in its output at target: "ESNext". At any lower target it lowers them, but on a runtime
without Symbol.metadata it hands them no metadata object, which every decorator here reports as
MissingDecoratorMetadataError. Install the symbol in a module the entry point imports first:
(Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata");Not every toolchain lowers them yet. Node's own type stripping rejects decorators; Next.js cannot select the 2023-11
version (vercel/next.js#96328 adds it); and Bun 1.4.2 and earlier inject the wrong values, silently, into a module that
holds two decorated classes (oven-sh/bun#28316).
The specification → tsconfig setup covers the compiler options. And
decorators are optional: an app wired through toResolved, toDynamic and Module.fromBindings needs
none of this.
Explicit resource management types
The declarations use explicit resource management (AsyncDisposable, behind await using), which no numbered lib
declares yet. @types/node 22 or later loads those types, so a Node program needs nothing more. Any other program adds
ESNext.Disposable to lib once its runtime ships explicit resource management. A browser program that targets Safari,
which has not shipped it, keeps skipLibCheck on and calls disposeAsync() instead of await using.
Quick start
import { Container, injectable, token } from "@codefast/di";
interface Logger {
info(message: string): void;
}
const LoggerToken = token<Logger>("app:Logger");
@injectable([LoggerToken])
class CheckoutService {
constructor(private readonly logger: Logger) {}
complete(orderId: string): void {
this.logger.info(`Order ${orderId} completed`);
}
}
const container = Container.create();
container.bind(LoggerToken).toConstantValue({
info: (message) => console.log(`[app] ${message}`),
});
container.bind(CheckoutService).toSelf();
container.resolve(CheckoutService).complete("ORD-1001");
// Swap infrastructure without touching business classes
container.rebind(LoggerToken).toConstantValue({
info: (message) => console.log(`[test] ${message}`),
});@injectable([...]) declares a class's constructor dependencies, in parameter order. The decorator checks the list
against the constructor signature, so a dependency of the wrong type — or a list of the wrong length — is a compile-time
error.
Tokens
A token is a typed key that identifies a service. It pairs a name with a TypeScript type, and it's the identity you bind
and resolve against. Tokens compare by reference, so declare each one once and reuse the const.
import { token } from "@codefast/di";
const DbToken = token<Database>("app:Database");A class constructor works as a key too: container.bind(UserService).toSelf(), then container.resolve(UserService).
Bindings
A binding describes how the container produces a value for a token. You declare it as a chain, in one fixed order:
optional constraints, then a strategy (the to* step, which produces the value and registers the
binding), then an optional scope and lifecycle hooks. Only container.bind(key).to*(…) is required.
| Strategy | Produces |
|---|---|
.toConstantValue(value) |
A fixed value (always singleton) |
.toSelf() / .to(Constructor) |
A class instance built from its @injectable metadata |
.toDynamic(factory) |
A sync factory (ctx: ResolutionContext) => Value |
.toDynamicAsync(factory) |
An async factory — resolve it with the *Async container methods |
.toResolved(factory, deps) |
A factory called with its typed dependency tuple already resolved |
.toResolvedAsync(factory, deps) |
The same, returning a Promise |
.toAlias(targetToken) |
An alias — a redirect to another token's binding |
container.bind(ConfigToken).toConstantValue({ dbUrl: "postgres://…" });
container.bind(DbToken).toDynamicAsync(async (ctx) => {
const db = new Database(ctx.resolve(ConfigToken).dbUrl);
await db.connect();
return db;
});
container
.bind(UserServiceToken)
.toResolved((repo, config) => new UserService(repo, config), [UserRepository, ConfigToken]);Scopes
A scope determines how long an instance lives and which container owns it. The default is transient, so a binding with
no scope produces a new instance every time.
| Scope | Lifetime |
|---|---|
.singleton() |
One instance for the container that owns the binding; children resolve the same instance |
.scoped() |
One instance per child container, deactivated with it — resolving outside a child throws |
.transient() |
A new instance on every resolution — the default |
container.bind(DbToken).toDynamic(createDb).singleton();
container.bind(RequestContextToken).toSelf().scoped();Important. Resolving a
scopedbinding from a container with no child scope throwsMissingScopeContextError. Open a scope withcreateChild()first.
Lifecycle hooks
A lifecycle hook runs your code as an instance is created or torn down. .onActivation(fn) runs right after an instance
is created, and may replace it; .onDeactivation(fn) runs as the instance's lifetime ends — a singleton's when its
container is disposed or the binding is unbound, a scoped instance's when the child container that cached it is
disposed. A transient instance has no deactivation. Both hooks are also available container-wide, through
container.onActivation(token, fn) and container.onDeactivation(token, fn):
container
.bind(DbToken)
.toDynamicAsync(connectDb)
.singleton()
.onDeactivation((db) => db.close());Constraints
Several bindings can share one token, and a constraint decides which one a given resolve receives. A binding's set of
constraints is its slot — think of a slot as a labelled variant of the token.
Named — the common case. A named slot is keyed by a plain string. Declare it with whenNamed, and request it with
{ name }:
container.bind(LoggerToken).whenNamed("console").toConstantValue(consoleLogger);
container.bind(LoggerToken).whenNamed("file").toConstantValue(fileLogger);
container.resolve(LoggerToken, { name: "file" }); // → fileLoggerDeclare the names on the token and they become checked, completable literals at every bind and request site:
const LoggerToken = token<Logger, "console" | "file">("app:Logger");
container.bind(LoggerToken).whenNamed("file").toConstantValue(fileLogger);
container.resolve(LoggerToken, { name: "file" }); // { name: "fiel" } is a compile errorTagged — for typed, collision-proof keys. A criterion is a [key, value] pair. Declare the key once with
tag<Value>(name), then mint a criterion with key.of(value). The bind site and the resolve site share the same typed
key: a key declared tag<"s3" | "gcs"> refuses any other value, so the two sites can't drift apart.
import { tag } from "@codefast/di";
const Provider = tag<"s3" | "gcs">("app:provider");
container.bind(StorageToken).whenTagged(Provider.of("s3")).to(S3Storage);
container.resolve(StorageToken, { tag: Provider.of("s3") }); // → S3StorageHow a request selects a slot. In one line: a request matches a slot when it carries every tag the slot declares.
- Adding tags to a request makes it match more slots, not fewer — it's a superset filter.
- When several slots match, the slot declaring more of the request's tags wins (most-specific-wins).
- A tie between equally specific slots throws
AmbiguousBindingError. - A request with no criteria selects the binding marked
.whenDefault()— or, equivalently, the binding that declares no constraint at all.
Note.
key.of(value)interns each value: the same value always yields the same criterion object, which lets lookup compare by identity. A criterion built by hand — not through.of()— matches nothing.
{ tag: criterion } and { tags: [criterion] } are the same request — for resolve, and for inject, optional, and
injectAll alike. Chain .whenTagged(...) once per criterion a slot carries, and request several at once with
{ tags: [...] }.
One binding per slot; a collection with many(). Binding a token again in a slot it already fills replaces the
earlier binding — bind is last-wins per slot, so resolveAll then finds only the newer one. To register several
implementations for resolveAll and injectAll to gather, mark each one a collection member with .many():
container.bind(PluginToken).many().to(AuditPlugin);
container.bind(PluginToken).many().to(MetricsPlugin);
container.resolveAll(PluginToken); // → an AuditPlugin and a MetricsPlugin, in registration orderA member keeps the default slot and is never what a single resolve selects.
Predicate constraints — graph-aware selection. When a slot isn't enough — when you need to choose based on who is
resolving — pass a predicate to .when(ctx => boolean). It runs at resolve time, after slot matching. These ready-made
predicates ship from the root entry:
| Predicate | Matches when |
|---|---|
whenParentIs(token) |
the direct parent resolves token |
whenNoParentIs(token) |
there is no parent, or it resolves a different token |
whenAnyAncestorIs(token) |
some ancestor resolves token |
whenNoAncestorIs(token) |
no ancestor resolves token |
whenParentNamed(token, name) |
the parent resolves token at the slot named name |
whenAnyAncestorNamed(token, name) |
some ancestor resolves token at the slot named name |
whenParentTagged(criterion) |
the parent's slot carries that criterion |
whenAnyAncestorTagged(criterion) |
some ancestor's slot carries that criterion |
whenParentTaggedAll(criteria) |
the parent's slot carries all criteria in the array |
whenAnyAncestorTaggedAll(criteria) |
some ancestor's slot carries all criteria in the array |
For the exact matching and most-specific-wins rules, see the specification → Slots and last-wins.
Decorators
Decorators are optional: you can wire an entire app with explicit bindings and never write one. When you do reach for them:
@injectable([...deps])— declares a class's constructor dependencies, in parameter order.inject(token, options)— wraps one dependency with slot options (name,tag,tags).optional(token)— resolves toundefinedwhen nothing is bound.injectAll(token)— collects every matching binding into an array.@postConstruct()— runs after wiring;@preDestroy()runs on disposal. An async@postConstructneedsresolveAsync.
import { inject, injectable, injectAll, optional, postConstruct, preDestroy } from "@codefast/di";
@injectable([DbToken, optional(CacheToken), injectAll(PluginToken), inject(LoggerToken, { name: "audit" })])
class UserRepository {
constructor(
private readonly db: Database,
private readonly cache: Cache | undefined,
private readonly plugins: ReadonlyArray<Plugin>,
private readonly audit: Logger,
) {}
@postConstruct()
async init(): Promise<void> {
await this.db.warmCache();
}
@preDestroy()
async shutdown(): Promise<void> {
await this.db.flush();
}
}Accessor injection
Accessor injection sets a property from the container instead of through the constructor. inject doubles as an
accessor decorator:
import { inject, injectable, runWithContainer } from "@codefast/di";
@injectable()
class Controller {
@inject(LoggerToken) accessor logger!: Logger;
}
const fromContainer = container.resolve(Controller);
const byHand = runWithContainer(container, () => new Controller());An accessor resolves from the container that's constructing the instance. When something else owns the new — a router,
an ORM, a test helper — open that context with runWithContainer. Without one, the accessor throws
MissingContainerContextError. getActiveContainer() reads the open context. runWithContainer is synchronous
only: the context does not survive an await, so a callback returning a Promise resolves to never.
Auto-registration
To avoid binding classes one by one, let them register themselves.
@injectable(deps, { autoRegister: registry, scope }) records the class in a registry from
createAutoRegisterRegistry("app:Services"). The registry is a SyncModule: container.load(registry) binds the whole
set in one call, and container.unload(registry) removes it again.
Custom metadata
Some classes you can't decorate — a dependency's class, generated code, plain JavaScript. Wire them by supplying their
metadata through a MetadataReader, passed as Container.create({ metadataReader }). A reader reports constructor
parameters, lifecycle method names, and @inject accessors; delegate misses to defaultMetadataReader so decorated
classes keep working. The reader is fixed when the container is created, and inherited by its children.
Container
const container = Container.create();
container.resolve(ServiceToken); // throws TokenNotBoundError when unbound
container.resolveOptional(CacheToken); // undefined when unbound
container.resolveAll(HandlerToken); // every matching binding
container.has(ServiceToken); // true when this container or an ancestor binds it
const db = await container.resolveAsync(DbToken); // required for async bindingsSync vs async resolution
The rule is simple: if anything in the chain is async, resolve with an *Async method. A synchronous resolve() that
reaches an async factory — its own, or a dependency's — throws AsyncResolutionError rather than silently returning a
Promise.
| Sync | Async counterpart |
|---|---|
resolve |
resolveAsync |
resolveOptional |
resolveOptionalAsync |
resolveAll |
resolveAllAsync |
An async @postConstruct or onActivation hook needs an *Async resolve too; on a synchronous path, it throws
AsyncActivationError. initializeAsync() warms every eligible singleton up front, so later resolve() calls stay
synchronous.
Child containers
A child container implements the request-scope pattern. It falls through to its parent's bindings and shares the
parent's singletons, while each scoped binding gets a fresh instance per child.
{
await using requestContainer = container.createChild();
requestContainer.bind(RequestIdToken).toConstantValue(crypto.randomUUID());
const service = requestContainer.resolve(RequestScopedService);
} // disposeAsync() runs here and releases what this child ownsExample 22 is the guide to this pattern: where each binding lives, the rules that keep a scope from leaking, and how it lands in TanStack Start server functions and server routes.
Validation
A captive dependency is a long-lived binding that holds a shorter-lived one — a singleton that depends on a scoped
or transient binding — which silently freezes that dependency for the singleton's whole life. validate fails fast on
captive dependencies, and on constraints no request can satisfy, before the first resolve. It also throws the error
resolve() would for a singleton graph that cannot resolve: a required dependency nothing binds, a slot nothing
matches, or a cycle.
validate(container); // throws on the first violation — ScopeViolationError, TokenNotBoundError, …A transient or scoped consumer's dependencies are left to resolve(), because a child container may still bind them.
Guard the call with a dev-only check (if (import.meta.env.DEV) or process.env.NODE_ENV !== "production") and the
production bundle drops validate and the introspection code behind it.
Disposal
Container implements AsyncDisposable, so await using runs every deactivation hook automatically as the block
exits, and disposing a child deactivates the scoped instances it cached. Synchronous using is a compile error: a
deactivation hook may be async, so the container implements Symbol.asyncDispose only.
Introspection
inspect(container) returns a ContainerSnapshot; lookupBindings(container, token) returns the snapshots of one
token's bindings; and generateDependencyGraph(container) returns a JSON graph. Adapters render that graph for common
viewers: toDotGraph, toMermaidGraph, toCytoscapeGraph, and toReactFlowGraph.
explain(container, token, options) answers why a request selects the binding it does, without instantiating anything:
every candidate each registry offered and what selection made of it, the rule that decided, and the binding it ends on.
It evaluates when() predicates the way resolve does, and options.ancestors explains a request made from inside
other resolutions, so a predicate that reads the parent sees the one it would:
const explanation = explain(container, LoggerToken, { ancestors: [CheckoutService] });
explanation.selected?.id; // the binding `resolve` would answer with
explanation.steps[0]?.rule; // "sole-predicate": the only candidate with a `when()` beat the restModules
A module is a reusable, stateless bundle of related bindings. Group them once, then load them into any container.
import { Container, Module } from "@codefast/di";
const InfrastructureModule = Module.create("app:Infra", (api) => {
api.bind(LoggerToken).toConstantValue(console);
});
const AppModule = Module.create("app:Root", (api) => {
api.import(InfrastructureModule);
api.bind(UserRepository).toSelf().singleton();
});
const container = Container.fromModules(AppModule);Module.createAsync lets you await during setup — for remote config or connections. Load those with
Container.fromModulesAsync or container.loadAsync. Modules are ref-counted: loading one twice counts once, and
unload removes its bindings only when the count reaches zero.
A module with no logic of its own can be written as a list instead. Each binding() states one binding with the fluent
chain's own vocabulary, is typed exactly as the chain is, and is checked where it is written:
import { binding, Module } from "@codefast/di";
const InfrastructureModule = Module.fromBindings("app:Infra", [
binding(LoggerToken, { toConstantValue: console }),
binding(UserRepository, { toSelf: true, scope: "singleton" }),
binding(CacheToken, { toDynamic: () => new Map(), whenNamed: "session", scope: "scoped" }),
]);It loads exactly as the equivalent Module.create would, and never slower: each binding registers in its final shape,
so one with a slot, a scope or a hook skips the chain steps a fluent setup pays on every load. A declared module mixes
into a fluent one with api.import(...).
Testing
Give every test its own container and dispose it afterwards. A container shared across tests carries cached singletons from one into the next.
let container: Container;
beforeEach(() => {
container = Container.create();
});
afterEach(async () => {
await container.disposeAsync();
});To swap one binding out of a module you already loaded, bind it at a child container — the child has no binding of its own yet, and resolution prefers the child over its parent:
const testContainer = Container.fromModules(AppModule).createChild();
testContainer.bind(DbToken).toConstantValue(fakeDatabase);rebind() is for replacing a binding that already exists in the same container — it throws
RebindUnboundTokenError when the token is only bound at a parent, so it is the wrong tool for the override above.
To construct a class the container was never told about — plain JavaScript, generated code, a class you cannot decorate
— supply the metadata yourself. Pass the reader to Container.create: a reader bound through MetadataReaderToken on
the container you are about to use is read too late, because the resolver was built with the container.
const container = Container.create({ metadataReader: customReader });For unit tests where every collaborator should be a mock, @codefast/di-testing builds the
mocks from the class's own declared dependencies, through a real container.
Errors
Every error extends DiError and carries a stable code, so you can branch on the code rather than the message:
| Error | Code | Raised when |
|---|---|---|
TokenNotBoundError |
TOKEN_NOT_BOUND |
No binding exists for the token in the container chain |
NoMatchingBindingError |
NO_MATCHING_BINDING |
The token has bindings but none matches the request's slot |
AmbiguousBindingError |
AMBIGUOUS_BINDING |
Several bindings match with no more-specific winner |
CircularDependencyError |
CIRCULAR_DEPENDENCY |
A cycle appears on the resolution path |
AsyncResolutionError |
ASYNC_RESOLUTION |
A sync resolve() reaches an async factory |
AsyncActivationError |
ASYNC_ACTIVATION |
A @postConstruct or onActivation hook returns a promise on a sync path |
ScopeViolationError |
SCOPE_VIOLATION |
validate finds a captive dependency |
MissingScopeContextError |
MISSING_SCOPE_CONTEXT |
A scoped binding is resolved outside a child container |
MissingContainerContextError |
MISSING_CONTAINER_CONTEXT |
An @inject accessor initializes with no container open |
DisposedContainerError |
DISPOSED_CONTAINER |
A disposed container is used |
A miss below the token you asked for names its path: TokenNotBoundError and NoMatchingBindingError carry path, and
the message ends with it — Path: UserService → UserRepository → app:Logger.
The full taxonomy — including MissingMetadataError, InvalidMetadataError, RebindUnboundTokenError,
AsyncModuleLoadError, InvalidBindingDeclarationError, and the rest — is exported from the root entry and from
@codefast/di/errors.
Subpath exports
Prefer the root entry: it re-exports the whole public API. Reach for a subpath only to trim a bundle, or to pull in something the root doesn't surface.
Every module is also published as a subpath that is its source path, with no exception: the model under
@codefast/di/core/* (core/token, core/tag, core/module, …), the error taxonomy at @codefast/di/errors, the
runtime under container/*, injection/*, lifecycle/*, and resolution/* (for example
@codefast/di/resolution/select/constraints), decorators and metadata under decorators/* and metadata/*, and
introspection under introspection/*: @codefast/di/introspection/inspector, @codefast/di/introspection/explanation,
@codefast/di/introspection/dependency-graph, and
@codefast/di/introspection/graph-adapters/{dot,mermaid,cytoscape,reactflow}.
Benchmarks
A first-party benchmark suite lives in the monorepo, at benchmarks/di. It runs the same
workloads through @codefast/di, InversifyJS, Awilix, tsyringe, Brandi, ditox, and injection-js, and its RESULTS.md
ledger records the numbers alongside the method that produced them. Run it yourself rather than taking any figure on
faith.
Documentation
- Rendered docs on codefastlabs.com
spec/— the behavioural contract: public API, semantics, and errors, one document per API area.ARCHITECTURE.md— the internal shape and the invariants the hot paths depend on.DECISIONS.md— why the API looks like this, and what it did not take from InversifyJS v8.examples/— runnable examples from basic tokens to a multi-file Ports & Adapters app.CONTRIBUTING.md— the package workflow: build, exports mirror, tests, and the perf guard.CHANGELOG.md— release history.
Contributing
See the repo-wide contributing guide for setup and conventions, and this package's
CONTRIBUTING.md for the resolver perf guard and the exports mirror.
License
Released under the MIT License.