Mental model. Decorators only record metadata — which tokens a class needs, which methods are lifecycle hooks. The container reads that metadata through a swappable port and does the actual work. Decorators are syntactic sugar; the core container does not depend on them.
They use standard decorators — the TC39 decorators proposal, at Stage 2.7 — and Symbol.metadata. Neither
experimentalDecorators: true nor reflect-metadata is needed.
Usage
Standard decorators do not support parameter decorators (TS1206). @inject on a constructor parameter is only
available with experimentalDecorators: true (legacy). The solution: @injectable() takes a deps array that
declares the constructor order explicitly — the same pattern as Angular Ivy.
Normative — the deps array is checked against the constructor at compile time, in both directions.
- Each element's resolved value must match its parameter (order,
optional,injectAll).- For a literal deps tuple the arity must match exactly — a list longer than the constructor is rejected rather than resolved and discarded.
- Optional trailing parameters admit every arity they declare, and a rest parameter admits any list.
- A deps array whose length the compiler cannot know skips the arity check. That spelling is also how a class deliberately declares more dependencies than its constructor takes (for the dependency graph's edges); the surplus values are resolved and discarded.
import { injectable, inject, injectAll, optional } from "@codefast/di";
// A class with no deps
@injectable()
class ConsoleLogger implements LoggerService {
log(msg: string) {
console.log(msg);
}
}
// A class with deps — declared explicitly through the deps array
@injectable([Logger, Config])
class App {
constructor(
private logger: LoggerService,
private config: AppConfig,
) {}
}
// Optional dependency
@injectable([Logger, Config, optional(Analytics)])
class App {
constructor(
private logger: LoggerService,
private config: AppConfig,
private analytics?: AnalyticsService,
) {}
}
// Multi dependency — inject every binding of a token as an array
@injectable([injectAll(Plugin)])
class PluginRunner {
constructor(private plugins: ReadonlyArray<Plugin>) {}
}Named / tagged / multi inject
inject(), optional() and injectAll() are plain functions returning an InjectionDescriptor:
@injectable([inject(Logger, { name: "console" }), inject(Engine, { tag: Fuel.of("electric") })])
class Dashboard {
constructor(
private logger: LoggerService,
private engine: Engine,
) {}
}
// Combining optional + named
@injectable([inject(Logger, { name: "file" }), optional(Analytics)])
class Reporter {
constructor(
private logger: LoggerService,
private analytics?: AnalyticsService,
) {}
}
// injectAll — inject every matching binding as a read-only array, with an optional named filter
@injectable([injectAll(Plugin), injectAll(Logger, { name: "audit" })])
class Runner {
constructor(
private plugins: ReadonlyArray<Plugin>,
private auditLoggers: ReadonlyArray<LoggerService>,
) {}
}Type signatures. All three take a token plus the same optional InjectOptions, and return an InjectionDescriptor:
inject for a required dependency, optional returning undefined when there is no binding, injectAll collecting
every matching binding into an array.
InjectOptionshas three fields:name,tag(shorthand for one criterion, folded intotagswhen the descriptor is built — seeResolveOptions), andtags.InjectionDescriptoris aDependencySlotcarrying a value type parameter —multiistrueexactly wheninjectAllcreated it. It comes with the type guardisInjectionDescriptor(value).
Exact shape:
src/injection/descriptor.ts—injectAll,optional,isInjectionDescriptor,InjectionDescriptor,InjectOptions;src/decorators/inject.ts—inject.
InjectableDependency — one element of the deps array
/**
* A valid element in the deps array of @injectable().
* - Token<Value> → plain inject: resolve the token, throw if there is no binding
* - Constructor<Value> → plain inject: resolve the class, throw if there is no binding
* - InjectionDescriptor → decorated inject: inject(), optional(), injectAll()
* Use it for named/tagged/optional/multi injection
*/
type InjectableDependency<Value = unknown> = Token<Value> | Constructor<Value> | InjectionDescriptor<Value>;Normative — normalization at decoration time.
@injectable([...])normalizes the wholeInjectableDependency[]intoParamMetadata[]when the class is decorated, so the resolver only ever reads plain records:
Token<Value>orConstructor<Value>→{ index, token, optional: false, multi: false }InjectionDescriptor<Value>→{ index, token, optional, multi }plusnameandtagswhen it carries them — a plain record, even when the descriptor came from the dual-roleinject()functionAn absent
nameortagsis left out of the record, never set toundefined— the shapeexactOptionalPropertyTypesexpects.
InjectableDependency is exported from @codefast/di (see Public API).
InjectableOptions and the full signature
InjectableOptions has two fields: autoRegister (the registry a class registers itself into; leave it out and it does
not self-register — see Auto-registration) and scope (the scope used when self-registering,
ignored without autoRegister, defaulting to "transient"). It is exported from @codefast/di.
injectable has two overloads, each returning a class decorator: injectable() for a class with no deps, and
injectable(deps, options?), where deps is a readonly InjectableDependency[] and options an InjectableOptions.
options needs deps beside it — @injectable([], { autoRegister }) for a dependency-free class that self-registers.
Inheritance — explicit, no magic
Normative. Every dep must be declared explicitly — there is no implicit inheritance injection. A subclass that declares none of its own constructor metadata yet inherits a base that declares some — the natural
class Derived extends Base {}with an implicit constructor — is rejected withMissingMetadataError, not built withundefinedarguments. Give it its own@injectable([...])—@injectable([])if it really takes none — or bind it withtoDynamic()/toResolved(). An explicit constructor alone does not help: one without parameters is rejected the same way, and one with parameters is rejected as any undecorated class is. A subclass whose own@injectable([])declares zero deps is built with zero arguments, as declared, and one extending a base that declares no deps needs no metadata of its own at all.
@injectable([Logger])
class BaseService {
constructor(protected logger: LoggerService) {}
}
// The child redeclares everything — explicit
@injectable([Logger, UserRepo])
class UserService extends BaseService {
constructor(
logger: LoggerService,
private repo: UserRepository,
) {
super(logger);
}
}MetadataReader — the port interface
Mental model. The container never touches Symbol.metadata itself. It asks a MetadataReader three questions about
a class — constructor deps, lifecycle methods, accessor fields — and a test can answer those questions with a fake.
The port has three methods:
| Method | Answers | Required? |
|---|---|---|
getConstructorMetadata(target) |
The constructor's dependencies: a list of ParamMetadata — a DependencySlot plus its index |
Yes |
getLifecycleMetadata(target) |
Two lists of method names, postConstruct and preDestroy, called in the order they appear in the class |
Yes |
getAccessorMetadata(target) |
The list of @inject accessor fields, each with key and descriptor |
Optional |
If a reader omits getAccessorMetadata, no class ever gets a container context opened for it, so every accessor
injection throws MissingContainerContextError unless the caller opens one with runWithContainer
(Property injection).
Exact shape:
src/metadata/types.ts—MetadataReader,ConstructorMetadata,ParamMetadata,LifecycleMetadata.
Installing your own reader
Normative. The resolver is handed its reader when it is constructed, which happens inside the container's constructor. The only source resolution is guaranteed to read is therefore
ContainerOptions.metadataReader(Creating a container). This reader outranks anyMetadataReaderTokenbinding, and children inherit it (a child calls the parent's#getMetadataReader()again when building its own resolver).
import { Container } from "@codefast/di";
const container = Container.create({ metadataReader: customReader });MetadataReaderToken — the binding, and its limits
import { MetadataReaderToken } from "@codefast/di";
const root = Container.create();
root.bind(MetadataReaderToken).toConstantValue(customReader);
const app = root.createChild(); // app's resolver is built after the binding exists → it sees the readerBinding the token on the very container you are using is invisible to every path: the constructor already ran before
the binding existed, so the resolver keeps the default reader, and an undecorated class whose constructor declares
parameters throws MissingMetadataError.
Normative — one container, one reader. The reader is fixed when the container's resolver is built;
validate(),inspect(),generateDependencyGraph()andunbind*all answer using that same reader. Introspection cannot disagree with resolution.
Normative — a reader is asked about a class once. Every container that reads through the same reader (a child inherits its parent's) shares that reader's answers for the life of the process, so a reader must answer from the class alone — never from state that changes after the class is defined.
MetadataReaderToken has type Token<MetadataReader> and is exported from @codefast/di.
SymbolMetadataReader — reading metadata
The default implementation reads straight from Symbol.metadata — there is no WeakMap mirror. Because Symbol.metadata
is not yet defined natively on every runtime (current Node.js returns undefined), the codebase normalizes it once at
module load: METADATA_SYMBOL = Symbol.metadata ?? Symbol.for("Symbol.metadata"). Babel and esbuild use the same
fallback when transforming decorators, which keeps the symbol consistent. TypeScript does not: it creates no metadata
object at all when Symbol.metadata is missing, which is why such a runtime installs the symbol first
(tsconfig setup). Once a runtime has a native Symbol.metadata, ?? picks the native symbol.
The list of @inject accessor fields is obtained through getAccessorMetadata(target).
getConstructorMetadata(target) only describes the constructor's dependencies; it does not stand in for accessor
fields.
Normative — no leaking of parent metadata. If a child extends a parent but has no
@injectable(),getConstructorMetadatareturnsundefinedfor it — the parent class's metadata is never silently leaked. What the container then does follows Inheritance: it throwsMissingMetadataErrorwhen the parent declares deps, and builds the child with no arguments when the parent declares none.
Property injection through the accessor field decorator
Mental model. @inject(Token) accessor field fills a field from the container during new, in the same call
frame, as the class's fields initialize — so the constructor body already sees the field, and nothing outside the class
has seen the instance yet. It works because the resolver opens a "current container" context around the new and the
field's initializer reads it.
Standard decorators support accessor. @inject(token) is a field decorator on an instance accessor.
@injectable()
class Dashboard {
@inject(Logger) accessor logger!: LoggerService;
@inject(Database) accessor db!: DatabaseService;
}Normative —
static accessoris not supported. A static initializer runs when the class is defined, outside the reach of bothrunWithContainerandnew. TypeScript rejects it at the decoration site (TS1240), because the decorator only accepts a context withstatic: false; untyped code getsStaticMemberDecoratorErrorwhen the decorator runs. On toolchains that do not invoke decorators for static fields, that error only surfaces if it runs.
The mechanism — initialization order
@inject(token) on an accessor writes the token into Symbol.metadata through context.metadata, and installs an
initializer through context.addInitializer that injects the value into each instance. The order:
1. accessor initializers run as the fields initialize — the property-injected fields are set
2. the constructor body runs — it can already read the injected fields
3. @postConstruct() runs — so can it// The container handles property injection for you
const dash = container.resolve(Dashboard);
// dash.logger → LoggerService from the same container
// dash.db → DatabaseService from the same containerConstruction (TC39) and activation (container). One resolve consists of (1) construction — the field and
accessor initializers, then the constructor body, all before new returns (see the
decorators proposal); and (2) activation — @postConstruct() then
onActivation(), called by the resolver/lifecycle after (1) has completed.
Outside a container context
If the class is new-ed by hand (not through the container), the accessor initializer has no container → it throws
MissingContainerContextError, carrying the class name (className) and the accessor name (accessorName) separately.
When other code (a router, an ORM, a test helper) owns the new, wrap the call site in runWithContainer — both it and
getActiveContainer are exported from @codefast/di:
import { runWithContainer } from "@codefast/di";
const instance = runWithContainer(container, () => new Dashboard());Normative. Only accessor injection is bridged. Lifecycle belongs to the resolver, so a hand-built instance does not run
@postConstruct, and the container does not dispose it either.
How the container context is passed — a module-level active container
Normative. An accessor's
context.addInitializercallback runs synchronously as the instance's fields initialize, in the same call frame asnew. The container exploits this with a module-level active container variable:
runWithContainer(container, fn)sets the active variable to the given container, runsfn, then restores the previous value in afinallyblock — so it is correct even when the constructor throws, and nested calls (A builds B builds C) restore in the right order. It is synchronous only: thefinallyrestores the previous value the momentfn's synchronous run returns, so the context does not survive anawait. A callback returning aPromiseis rejected by the return type (it resolves tonever).getActiveContainer()reads the currently active container, returningundefinedwhen no context is open.
Exact shape:
src/ambient-container.ts.
The container opens that context itself around every new it performs for a class with accessor injection, so a resolve
needs no runWithContainer at the call site — only a hand-built instance does.
The inject() accessor decorator reads the active context in the initializer. In its accessor-decorator role, the
implementation of inject() does three things:
- It throws if
context.staticistrue. - It writes
{ key, descriptor }intoSymbol.metadatathroughcontext.metadatasoMetadataReadercan read it back. - It installs an initializer via
context.addInitializer. During a container resolve, that initializer resolves through the engine's own resolution path, which the resolver opens beside the active container: the accessor's dependency is resolved as a dependency of the class being built, so awhen()constraint sees that class as its parent and a cycle through an accessor raisesCircularDependencyError. Outside a resolve it falls back togetActiveContainer()— with no container it throwsMissingContainerContextErrorcarrying the class name and the accessor name — and with one it callscontainer.resolveas any caller would. Either way theresolveOptionalvariant is used for an optional descriptor, and the value is written throughcontext.access.set.
It does not override get/set; it only adds an initializer.
Exact shape:
src/decorators/inject.ts.
The flow with runWithContainer:
resolver.resolve(Dashboard)
→ opens the active container and the engine's resolution path, then new Dashboard(...args)
→ accessor initializers run (addInitializer callbacks)
→ the engine path resolves each field's token // read in the same call frame
→ context.access.set(this, value) // inject the value
→ Dashboard constructor body runs // the fields are already set
→ the context is restored // the previous one, in a finally
→ @postConstruct() runs (after the context is closed)A hand-built instance under runWithContainer(container, () => new Dashboard()) takes the same steps with only the
active container open, so its initializers go through container.resolve.
Concurrency safety.
_activeContaineris a module-level variable. In a single-threaded environment (the Node.js event loop) this is safe, because JS has no true parallelism.runWithContainerwithtry/finallyguarantees that nested construction (A injects B injects C) stacks correctly. Should the library ever need to support Worker threads, each Worker has its own module scope — there is no shared state.
Design choices
Constructor injection is still preferred — immutable, easy to test, no container context needed. Property injection through
accessoris useful when a class extends a framework that owns the constructor, or when you need to break a circular dependency.
@injecton a plain field is not supported (@inject(Logger) logger!). Property injection only goes throughaccessor(@inject(Logger) accessor logger, …). A standard field decorator does havecontext.access; restricting this toaccessoris an API choice (a narrower surface), not a limitation of the proposal.
inject() is dual-role
inject() works both as a plain function (in a deps array) and as an accessor decorator. Its return type is the
intersection of InjectionDescriptor<Value> and ClassAccessorDecorator<unknown, Value>. Used in a deps array,
TypeScript matches the first half; used as a decorator, it matches the second. One function, one import — there is no
separate import for either role.
Decorator toolchain. Vitest uses its default transform (OXC). Test snippets that need standard decorators go through
@rolldown/plugin-babelwith@babel/plugin-proposal-decorators(version: "2023-11"). A transform around decorator metadata must keepinject()a callable object; useisInjectionDescriptor(value)before processing a deps array.
Method lifecycle decorators
@postConstruct() and @preDestroy() are method decorators on public instance methods with a string name; the
method name is written into Symbol.metadata, and nowhere else. The lifecycle manager calls a hook on the instance by
that name, so it can reach no other kind of method.
Normative — hook keys. Both decorators accept only a context with
static: false,private: falseand a stringname, so TypeScript reports a static, private (#name) or symbol-keyed hook at the decoration site (TS1241). Untyped code gets the matching error when the class is defined:StaticMemberDecoratorError,PrivateLifecycleMethodErrororSymbolKeyedLifecycleError. A private hook would otherwise passvalidate()and fail only at resolve or dispose. To run private code, call it from a public hook.
@injectable([Config])
class DatabaseService {
constructor(private config: AppConfig) {}
@postConstruct()
async initialize(): Promise<void> {
await this.connect(this.config.dbUrl);
}
@preDestroy()
async cleanup(): Promise<void> {
await this.disconnect();
}
}
container.bind(Database).to(DatabaseService).singleton();Normative.
- Several per class: a class may have several
@postConstruct()methods and several@preDestroy()methods. All of them are called in declaration order (top-down). If one throws, the remaining methods are not called and the error is propagated.- Across an inheritance chain:
@postConstruct()methods run base class first,@preDestroy()methods derived class first — teardown unwinds construction.- Scope:
@postConstruct()runs for every scope — each time a new instance is created.@preDestroy()runs for asingletonwhen its container is disposed or the binding unbound, and for ascopedinstance when the child container that cached it is disposed. Atransientinstance gets no@preDestroy().- Async contamination: an async
@postConstruct()forcesresolveAsync()— async contamination spreads along the entire dependency path.
Auto-registration
Mental model. A class can put itself on a list at module-load time; a container later binds everything on that list. The list is an ordinary object you create and pass around — never a global.
@injectable() supports autoRegister — a class registers itself into an explicit registry at module load time.
There is no global singleton.
// An explicit registry — not a global
const appRegistry = createAutoRegisterRegistry();
@injectable([Logger, Config], { autoRegister: appRegistry, scope: "singleton" })
class UserService { ... }
@injectable([Logger], { autoRegister: appRegistry })
class PostService { ... } // default scope: transient
const container = Container.create();
const count = container.loadAutoRegistered(appRegistry);
// count = 2Scope in auto-register. The default is
transient. Override it with{ autoRegister: registry, scope: "singleton" | "scoped" }.
Coexisting with explicit bind.
container.bind(UserService)afterloadAutoRegistered()applies slot-aware last-wins — the explicit binding replaces the auto-registered one when the slot is the same.
The AutoRegisterRegistry interface. AutoRegisterRegistry has two methods: register(target, scope) — called
automatically by @injectable({ autoRegister }) — and entries(), returning everything registered.
createAutoRegisterRegistry() builds a fresh registry.
Exact shape:
src/decorators/injectable.ts.
Rationale — why not a global registry. Global state creates an implicit side effect at module import time — hard to tree-shake, hard to isolate in tests.
createAutoRegisterRegistry()returns an ordinary object that can be passed around, mocked, or reset independently.
The decorator and helper list
| API | Kind | Target | Effect |
|---|---|---|---|
@injectable(deps, options?) |
decorator | class | Writes param metadata into Symbol.metadata. options.autoRegister registers into an explicit registry |
inject(token, options?) |
plain fn + accessor decorator | deps array / accessor field |
An InjectionDescriptor, or injection through an accessor |
optional(token, options?) |
plain fn | deps array | Like inject, but returns undefined when there is no binding |
injectAll(token, options?) |
plain fn | deps array | Resolves every matching binding into an array |
isInjectionDescriptor(v) |
type guard fn | — | Checks whether a value is an InjectionDescriptor |
@postConstruct() |
decorator | method | Writes the method name into Symbol.metadata — runs after construction, before caching |
@preDestroy() |
decorator | method | Writes the method name into Symbol.metadata — runs at deactivation (singleton or scoped) |
MetadataReaderToken |
Token<MetadataReader> |
— | The token for swapping the MetadataReader in tests |
createAutoRegisterRegistry() |
fn | — | Creates the explicit registry options.autoRegister takes |
runWithContainer(c, fn) |
fn | — | Runs fn with c active, so a hand-built instance's accessors can inject |
getActiveContainer() |
fn | — | The container active in the current synchronous call, or undefined |
SymbolMetadataReader |
class | — | The MetadataReader that reads what these decorators write |
defaultMetadataReader |
SymbolMetadataReader |
— | The shared instance a container uses when given no reader |
@singleton()and@scoped()do not exist. Scope is a binding-time concern — declared at.singleton()/.transient()/.scoped()in the fluent chain. A class does not decide its own scope.
There are no parameter decorators. Standard decorators do not support them (TS1206). The deps array replaces them entirely.
tsconfig setup
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"strict": true
}
}experimentalDecorators: true is not needed: standard decorators are TypeScript's default.
Normative — the program declares explicit resource management; the package does not. The published declarations name
Symbol.asyncDisposeandSymbol.dispose, andawait usingchecks a container againstAsyncDisposableandDisposable. A Node program gets all four from@types/node24 or later (types: ["node"]), which loads TypeScript'sESNext.Disposablelib. Any other program addsESNext.Disposableto its ownlib. In a browser program it goes besideDOM, and there it also asserts that the targeted browsers ship explicit resource management, which Safari does not yet — so the@codefast/typescript-configpresets leave it out, and a browser program on them keepsskipLibCheckon and callsdispose().target: "ESNext", as above, loads all ofESNextand needs nothing more. Without the types,container.d.tsfails with TS2550 underskipLibCheck: false, and the program's ownawait usingfails with TS2318 even underskipLibCheck: true.At runtime,
await usingneedsSymbol.asyncDisposewhen@codefast/diis evaluated, because the container's methods are keyed by it then. Every supported Node line ships it. On a browser that does not,dispose()still works.
Normative —
Symbol.metadatamust exist before a decorated class is defined. TypeScript emitscontext.metadataasundefinedon a runtime withoutSymbol.metadata, and every decorator here then throwsMissingDecoratorMetadataErrorat the declaration. The library does not install it — the package declares no side effects — so an app on such a runtime installs it in a module imported first:(Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata");The widening is what lets it type-check: TypeScript declares
Symbol.metadatareadonly, and alibwithoutESNext.Decoratorsdoes not declare it at all, so the bareSymbol.metadata ??= …compiles only in a.jsfile.Symbol.foris the key the default reader falls back to and the one esbuild emits, so every toolchain agrees on it. Node also cannot parse decorator syntax itself:target: "ESNext"leaves decorators in the output, so the code must go through a transpiler that lowers them (a lowertarget, esbuild, or Babel's2023-11decorators plugin).