Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/di · TypeScript · Stage 3 decorators

Dependency injection
the compiler checks.

Wire services with typed tokens and native decorators — no reflect-metadata, no runtime reflection — then add scopes, modules, introspection and an auto-mocking test bed as the graph grows. Around it sit the @codefast packages a React 19 product reaches for next.

Quick start
@codefast/di
import { Container, injectable, token } from "@codefast/di";

interface Logger {
  info(message: string): void;
}

const LoggerToken = token<Logger>("app:Logger");

@injectable([LoggerToken])
class OrderService {
  constructor(private readonly logger: Logger) {}

  place(sku: string): void {
    this.logger.info(`${sku} placed`);
  }
}

const container = Container.create();

container.bind(LoggerToken).toConstantValue({
  info: (message) => console.log(message),
});
container.bind(OrderService).toSelf();

container.resolve(OrderService).place("SKU-42");

Live

Every arrow
is real.

The quick start's OrderService, grown into a shop and running in your browser. The graph comes from generateDependencyGraph(), the badges from each binding's scope, and the log from the instances the container actually built. Open a request scope, resolve, resolve again, and watch what it reuses.

container.generateDependencyGraph()

shop:Loggerclass · singletonshop:ShopConfigconstant · singletonshop:Inventoryclass · singletonshop:PaymentGatewaydynamic · transientshop:PriceCatalogclass · singletonshop:RequestContextclass · scopedshop:OrderServiceclass · transient
  • singletonone instance, shared by every scope
  • scopedone per request scope
  • transientnew on every resolve

Resolve OrderService, or open a request scope first.

Inside a request scope the three singletons come back reused, the transient gateway and the root are new on every resolve, and the context is new per scope. The root container itself refuses to build an OrderService, because RequestContext is scoped: resolve with no scope open and the log shows that refusal, then opens one.

Why @codefast/di

Explicit by design,
checked by the compiler.

Every dependency is declared where it is consumed and checked where it is declared. The container does the construction; the compiler does the arguing.

01

Typed tokens

A Token<Value> flows through every bind → resolve path, and @injectable checks the dependency list against the constructor. This is the quick start’s OrderService; hand it the shop’s config token in place of the logger’s and the compiler says so before anything runs:

const LoggerToken = token<Logger>("shop:Logger");

@injectable([LoggerToken])
class OrderService {
  constructor(private readonly logger: Logger) {}
}

tsc --noEmit: 0 errors. The list names a Logger and the constructor takes one.

02

Native Stage 3 decorators

Six decorators cover dependencies and lifecycle. ReceiptMailer, which mails what place() returns, takes a required transport, an optional logger, every formatter bound and a named fallback transport, then connects and closes through the two hooks. No reflect-metadata, no experimentalDecorators, no runtime reflection.

imports, from @codefast/di
import { inject, injectAll, injectable, optional, postConstruct, preDestroy, token } from "@codefast/di";
interface Transport {
  connect(): Promise<void>;
  close(): Promise<void>;
  send(to: string, body: string): Promise<void>;
}
interface Formatter {
  format(receipt: string): string;
}
interface Logger {
  info(message: string): void;
}

const TransportToken = token<Transport>("app:Transport");
const FormatterToken = token<Formatter>("app:Formatter");
const LoggerToken = token<Logger>("app:Logger");

@injectable([
  TransportToken,
  optional(LoggerToken),
  injectAll(FormatterToken),
  inject(TransportToken, { name: "fallback" }),
])
export class ReceiptMailer {
  constructor(
    readonly transport: Transport,
    readonly logger: Logger | undefined,
    readonly formatters: Array<Formatter>,
    readonly fallback: Transport,
  ) {}

  @postConstruct()
  async connect(): Promise<void> {
    await this.transport.connect();
  }

  @preDestroy()
  async close(): Promise<void> {
    await this.transport.close();
  }
}

This page compiles them with the standard decorators transform; the same code runs unchanged once browsers ship them.

03

Scopes with validation

Singleton, scoped or transient per binding, and a binding may not outlive what it holds. This is the live container with one binding changed: a singleton OrderService would keep its first request’s context for the container’s whole life, so validate() refuses the graph up front and names the captive:

container.bind(RequestContextToken).to(RequestContext).scoped();
container.bind(OrderServiceToken).to(OrderService).transient();
container.validate();

validate() passed: no captive dependency, no unreachable constraint.

04

Modules and introspection

Bundle bindings into reusable, ref-counted modules. Ask any container what it holds with inspect(), or hand its dependency graph to a renderer.

  • DOT
  • Mermaid
  • Cytoscape
  • React Flow
The demo container’s bindings
tokenkindscope
shop:ShopConfigconstantsingleton
shop:Loggerclasssingleton
shop:PriceCatalogclasssingleton
shop:Inventoryclasssingleton
shop:PaymentGatewaydynamictransient
shop:RequestContextclassscoped
shop:OrderServiceclasstransient

Testing

Mock what
you don’t test.

@codefast/di-testing reads a class's declared dependencies and builds a mock for each — no per-collaborator bind. Here the live graph's OrderService is under four tests: every collaborator auto-mocked, two of them stubbed to drive a scenario, the request context replaced by a value, and a token the unit never declared refused at compile. The unit is constructed through a real container, so accessor injection and lifecycle hooks run exactly as in production. Use the built-in spy, as here, or pass vi.fn or jest.fn and assert with their matchers.

Read about the test beds
Unit tests
@codefast/di-testing
imports, from ./shop and @codefast/di-testing
import { TestBed, UndeclaredDependencyError } from "@codefast/di-testing";
import { expect, it } from "vitest";

import {
  InventoryToken,
  LoggerToken,
  OrderService,
  PaymentGatewayToken,
  PriceCatalogToken,
  RequestContextToken,
  ShopConfigToken,
} from "./shop";
it("reserves the stock before charging", () => {
  const { unit, mocks } = TestBed.solitary(OrderService).compile();

  unit.place("SKU-42");

  const inventory = mocks.get(InventoryToken);

  expect(inventory.reserve.mock.calls[0]).toEqual(["SKU-42"]);
});

Benchmarks

Measured, with
the method attached.

A first-party suite runs the same workloads through @codefast/di, InversifyJS, Awilix and tsyringe, every library interleaved so none of them rides the machine's drift. Its ledger records every figure beside the recipe that produced it and the machine it ran on, and the guide shows how to re-run any row. The scoreboard here is lifted from that ledger at build time, losses included.

benchmarks/di/RESULTS.md · BENCH_ISOLATE=true BENCH_MODE=full, one subprocess per scenario, libraries interleaved with rotating order

@codefast/di across 61 head-to-head rows

60 wins · 0 parity · 1 loss
How @codefast/di fared against each competitor
againstwins · parity · losses@codefast/di faster by
mediangeomean
vs inversify 8.2.3
45 · 0 · 0
2.33×2.84×
vs awilix 13.0.5
8 · 0 · 0
3.22×3.77×
vs tsyringe 4.10.0
7 · 0 · 1
6.00×4.89×

A ratio is @codefast/di’s throughput over the competitor’s on the same row, so 2× means twice the work in the same time.

The one loss: realistic-graph-cold-resolve at 0.89× of tsyringe, stay published, with the reason, under Where it loses.

Node 26.1.0 / V8 14.6, Apple M3 Max × 14, darwin/arm64 · last full re-measure

Packages

One repo, every layer.

Dependency injection, UI components, variant styling, appearance management, consent-gated tracking, and the tooling that keeps them honest — all published under @codefast.

@codefast/di

Flagshipv0.9.0

Lightweight dependency injection primitives for Codefast

Documentation

@codefast/di-testing

v0.1.3

Solitary and sociable auto-mocking test beds for @codefast/di

Documentation

@codefast/ui

v0.9.2

Core UI components library built with React and Tailwind CSS

Component docs

@codefast/tailwind-variants

v0.8.0

Tailwind CSS variants utilities with enhanced functionality and advanced type safety

Documentation

@codefast/theme

v0.8.3

Appearance management for React 19 with HIG-inspired vocabulary - optimistic updates, cross-tab sync, and FOUC-free SSR

Documentation

@codefast/tracking

v0.7.2

Consent-gated, type-safe event tracking for TanStack Start — client tracker and server consent helpers over a per-app Standard Schema event catalog

Documentation

@codefast/cli

v0.10.0

Developer CLI for the Codefast monorepo (arrange, audit, mirror, pack-slim, tag)

Documentation

@codefast/typescript-config

v0.9.0

Shared TypeScript configuration for the monorepo

Documentation

Get started

One command to start.

Add the package, declare a class's dependencies with @injectable, and resolve it from a container. Native decorators, no reflect-metadata, nothing to configure.

$pnpm add @codefast/di