Skip to content
codefastlabs

Command Palette

Search for a command to run...

v0.13.0· Overview

@codefast/di

Type-safe, zero-dependency dependency injection for TypeScript, on standard decorators

Type-safe dependency injection for TypeScript applications, with zero dependencies, built on standard decorators with no reflect-metadata and no experimentalDecorators.

npm version license

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 every bind → resolve path, so resolve() returns exactly the type you registered.
  • Standard decorators. @injectable, inject, optional, injectAll, @postConstruct, and @preDestroy declare 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, or transient, and call validate(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 build succeeds, then the bundle throws SyntaxError: Invalid or unexpected token as 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 scoped binding from a container with no child scope throws MissingScopeContextError. Open a scope with createChild() 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" }); // → fileLogger

Declare 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 error

Tagged — 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") }); // → S3Storage

How 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 order

A 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 to undefined when nothing is bound.
  • injectAll(token) — collects every matching binding into an array.
  • @postConstruct() — runs after wiring; @preDestroy() runs on disposal. An async @postConstruct needs resolveAsync.
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 bindings

Sync 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 owns

Example 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 rest

Modules

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.