A guided read of how this dependency-injection engine applies real computer-science and software-engineering ideas —
architectural patterns, design patterns, algorithms and data structures, TypeScript type techniques, performance
engineering, and testing. The goal isn't to document the API (that's README.md) or the contract (that's
SPEC.md); it's to give a newcomer a map of which techniques live where, and enough of the reasoning to
learn from them.
How to use this document. It teaches by pointing at real code. Every technique below cites the file that implements it — open that file, that's where the learning is. The document has two parts you can read in either order:
- Part 1 — a guided tour follows one
resolve()call frombind()to a returned instance, naming each concept as it comes up. Read this first for the big picture. - Part 2 — a catalogue groups the same techniques by category so you can study one theme (say, all the caching, or all the TypeScript tricks) in depth, or come back to look one up.
A note on the performance claims. Several techniques here exist for speed. Where this document explains why a
shape is fast, treat that as a hypothesis tied to a particular Node/V8 version and machine — the numbers behind it live
with the benchmarks/di-inversify suite, which is how you'd actually check.
This document teaches the technique and the reasoning, not a scoreboard. When in doubt, read the code and measure.
The three companion documents, and when each is the one you want:
| You want to know… | Read |
|---|---|
| how to use the library | README.md |
| the exact behavioural contract | SPEC.md |
| what the shape is and what it guarantees | ARCHITECTURE.md |
| which techniques it applies, and how to learn | this file |
Part 1 — The life of a resolve()
Follow one dependency from the moment it's declared to the moment an instance comes back. Each stop names the technique in play and links the code; Part 2 goes deeper on each.
The whole path on one page — a request tries the cheapest lane that can work, then dispatches on scope and kind:
flowchart TD
A["container.resolve(token)"] --> B["resolveFromContext"]
B --> C{"getFastDefault field hit?"}
C -->|hit| D["#resolveDefaultEntry"]
C -->|miss| E{"lookup memo hit?"}
E -->|hit| D
E -->|miss| F["full selection: name / tag / predicate"]
F --> D
D --> G{"scope?"}
G -->|transient| H{"kind?"}
H -->|"class or resolved, at top level"| I["compiled plan"]
H -->|"dynamic or nested level"| J["interpreted resolve + cycle guard"]
G -->|singleton| K{"cached on binding.instance?"}
K -->|yes| L["return cached"]
K -->|no| J
G -->|scoped| M{"cached in scope?"}
M -->|yes| L
M -->|no| J
I --> N["instance"]
J --> N
L --> NStop 0 — Declaring a binding: a fluent builder that is also a type-level state machine
container.bind(Storage).to(S3Storage).whenTagged(Region.of("eu")).singleton();That chain is one object. BindingChain implements every step interface at once
— BindToBuilder, BindingBuilder, SingletonBindingBuilder, and the rest — and each method's return type, not a
runtime check, decides what you may call next:
export class BindingChain<Value>
implements
AliasBindingBuilder,
BindingBuilder<Value>,
BindToBuilder<Value>,
ConstantBindingBuilder<Value>,
ScopedBindingBuilder<Value>,
SingletonBindingBuilder<Value>,
SingletonLifecycleBuilder<Value>,
TransientBindingBuilder<Value> { … }bind() hands you back the BindToBuilder face, on which singleton() and whenTagged() simply don't exist — so
bind(T).singleton() is a compile error, not a runtime one. This is the Builder pattern carrying a type-level
ordering guarantee (see SPEC — the canonical chain order). Part 2 covers both the
builder and the type mechanics.
Stop 1 — Registration: one construction site, last-wins, versioned
to()/toConstantValue()/toDynamic() all funnel through #register, which calls the single binding construction
site, createBinding(). Every binding in the process is built by that one literal in one fixed
field order, so they all share a single V8 hidden class — a performance technique
covered later.
The binding lands in the BindingRegistry — the Registry pattern, a token→bindings store
with side indexes for id and criterion lookups. Registration is last-wins: add() finds any existing binding whose
slot is equal and displaces it (see SPEC — slots and last-wins). Every mutation bumps a
monotonic #version counter — the seed for all the cache invalidation later.
Stop 2 — Asking for a value: a tiered fast-lane dispatch
container.resolve(Storage) reaches the engine, DependencyResolver, and the hot entry
resolveFromContext. It tries the cheapest thing that can work first, and only falls through on a miss — a fast-lane
dispatch:
- a direct field read on the own-registry fast-default index (
getFastDefault); - else the chain-versioned lookup memo (
BindingLookupCache); - else full candidate selection.
The memo mirrors the container's parent chain, so a child answers from its own cache without re-walking the hierarchy. This tiering — and its inline one-entry cache — is in Part 2.
Stop 3 — Selecting the binding: interning, a bitmask prefilter, and specificity
If selection is needed, the request's criteria (a name, a tag, a predicate) are matched against candidate slots. Three techniques stack here:
- Interning / flyweight. A tag criterion is minted once by
TagKey.of()and cached, so equal criteria are the same object and can be compared by identity. See interning. - Bitmask subset prefilter. Each tag key owns one bit; a slot's keys OR into a mask; a single
&rejects any slot the request doesn't cover before a value is read. See the bitmask prefilter. - Most-specific-wins. Among survivors,
selectBindingprefers a predicate-bearing candidate, then the one with the most tags, else raisesAmbiguousBindingError.
The one rule for "does this slot match this request" lives in a single function, matchesSlot() — a deliberate
single source of truth so the fast lanes can't drift from the slow one.
Stop 4 — Deciding how to build: a tagged union, and compile-vs-interpret
A Binding is a discriminated union keyed by kind (class, dynamic, constant, alias, …). The dispatcher
#resolveDefaultEntry switches on scope then kind:
if (scope === "transient") {
if (binding.kind === "dynamic") { … }
// Compiled plans only run at the top level — inner levels keep the runtime cycle guard.
if ((binding.kind === "class" || binding.kind === "resolved") && resolutionPath.length === 0) {
const plan = this.#getInstantiationPlan(binding);
if (plan !== null) { return plan(); }
}
} else if (scope === "singleton") {
if (this.#isPlainConstant(binding)) { return binding.value; }
const cachedSingleton = binding.instance;
if (cachedSingleton !== NO_INSTANCE) { return cachedSingleton; }
…
}A top-level transient class/resolved graph is compiled once into a nested-constructor closure by
InstantiationPlanCompiler and then runs with no per-resolve bookkeeping —
the classic compile-vs-interpret trade. A dependency the compiler can't see through (a factory, a scoped binding, a
hook) becomes an escape: a re-entry into the interpreter seeded with exactly the ancestors it would have had, so
behaviour is identical to never compiling. See compile-vs-interpret and
the escape hatch.
flowchart TD
subgraph plan["Compiled once into a nested-constructor closure"]
Root["Root — class"] --> Aa["A — class"]
Root --> Bb["B — class"]
Aa --> Cc["C — class"]
end
Bb -->|"a dep the compiler cannot see through"| Esc["escape thunk"]
Esc ==>|"re-enter interpreter, seeded with the same ancestors"| RT["interpreted resolver + cycle guard"]Notice the comment a constant is a singleton that is already its own instance and the plain-constant test living
inside the singleton branch — that placement is a deliberate dispatcher-ordering technique.
Stop 5 — Guarding against cycles: four mechanisms, one per lane
Before a factory runs, the engine must catch A → B → A. It uses four different cycle detectors, each the cheapest
correct one for its lane:
| Lane | Mechanism |
|---|---|
| sync transient-dynamic | a boolean binding.inFlight flag — O(1) exact membership |
| everything else sync | push/pop one shared path array (resolution-path.ts) |
| async, inside a cascade | inFlight again, cleared when the factory returns its promise |
| async, across an await | an append-only branch path read by depth |
flowchart TD
Q{"which resolution lane?"}
Q -->|"sync transient-dynamic"| S1["binding.inFlight flag — O(1); the sync call stack IS the path"]
Q -->|"sync, everything else"| S2["shared path array, push/pop; linear scan, then a Set past depth 32"]
Q -->|"async, inside one cascade"| S3["binding.inFlight flag, cleared when the factory returns its promise"]
Q -->|"async, across an await"| S4["append-only branch path, read by depth, copy-on-fork"]Why a flag suffices for sync (one call stack can't interleave, so the flag is path membership) and why async needs two lanes is the richest algorithmic story in the codebase — see cycle detection.
Stop 6 — Constructing: ambient context, and reused scratch space
For a class binding the engine calls new, but constructor-parameter @inject accessors need to know which
container is resolving. The engine sets a module-level activeContainer around the
call — an ambient-context pattern — so the accessor reads it at property-access time and restores it after.
The scratch arrays that track the resolution path aren't allocated per call; they come from an object pool of resolution contexts reused by depth. See ambient context and the object pool.
Stop 7 — Lifecycle: a sentinel, cached singletons, and hooks
Finally the value is produced, possibly run through onActivation hooks, and — if singleton — cached. The singleton
lives directly on binding.instance, a field read rather than a map lookup, with a null-object sentinel
NO_INSTANCE distinguishing "not resolved yet" from a legitimately cached undefined:
export const NO_INSTANCE: unique symbol = Symbol("di:no-instance");The value comes back to the caller. That's one resolve(). Part 2 revisits every stop as a standalone lesson.
Part 2 — A catalogue of techniques
A. Architectural patterns
Strict downward-only layering. The src/ tree is organised into layers, and value imports only ever point down;
type-only imports may point up because they erase at build time and couple nothing at runtime. core/ (the model) knows
nothing of resolution/ (the engine); the engine imports the model, not vice versa. You can see it in the import block
at the top of resolver.ts. ARCHITECTURE.md has the full layer
diagram. Lesson: a dependency direction can be an architectural invariant, and the compiler can hold it if you keep
value-imports one-way.
flowchart TD
CO["container / introspection — the public surface"]
DE["decorators / metadata"]
RE["resolution — the engine"]
LI["lifecycle / ambient — per-container state"]
MO["core / errors / injection — the model"]
CO --> DE
DE --> RE
RE --> LI
LI --> MOAn arrow reads "imports / depends on"; a value import only ever points down. A type-only import may point up (it erases at build time), so it isn't drawn here.
Ports & adapters (hexagonal) — the metadata seam. Reflection over decorators is abstracted behind a port,
MetadataReader; the default adapter is
SymbolMetadataReader, and a consumer can inject their own. A foreign reader
is wrapped by verifyingMetadataReader — the Decorator pattern — which
validates its answers before use, while the trusted default is passed through untouched. Lesson: an injection point for
a whole subsystem, plus a validating wrapper that only pays for untrusted implementations.
Registry. BindingRegistry is the single store of truth for what's bound: a primary
token→bindings map, plus by-id, named, and tagged side indexes that exist so the hot lookups don't scan. Lesson: a
registry earns its side indexes only where a scan would otherwise be on a hot path.
Plan-compile vs. interpret. Two execution strategies for the same graph: an interpreter (the general resolver) and a
compiler (InstantiationPlanCompiler) that turns a static subgraph into a
nested-constructor closure once, so repeated resolves skip the per-hop machinery. The dispatcher picks the compiled path
only at the top level (resolutionPath.length === 0). Lesson: compile the part of the graph that is static and known;
keep an interpreter for the part that isn't.
Escape hatch / partial compilation. The compiler doesn't give up when it hits something opaque (a factory, a hook, a
too-deep class). It emits an escape thunk (#compileEscapeThunk) that
re-enters the interpreter seeded with the exact ancestors the interpreted path would have had — so cycle detection and
constraint contexts behave identically. This "indistinguishable from interpreting" property is a correctness invariant
(see ARCHITECTURE.md), pinned by tests/unit/resolution/plan/instantiation-plan-escapes.test.ts.
Lesson: partial compilation is only safe if the escape is behaviourally identical to the slow path — design the seam so
that's true by construction.
Fast-lane dispatch. resolveFromContext in resolver.ts is a waterfall: a field read
first, then a versioned memo, then full selection. Each tier is a superset-correct shortcut for the tier below. Lesson:
order a hot path cheapest-first, and make sure every shortcut yields exactly what the general path would.
The memo behind that waterfall isn't one shared table — each container's
BindingLookupCache links to its parent's, mirroring the container
chain, so a child answers from its own cache without re-walking the hierarchy on every hop:
flowchart LR
subgraph childC["child container"]
CR["resolver"]
CLC["lookup cache"]
end
subgraph parentC["parent container"]
PR["resolver"]
PLC["lookup cache"]
end
CR -->|parent| PR
CLC -->|parent| PLCLesson: when a lookup has to consult a hierarchy, giving each level its own cache that points at the parent's turns a repeated walk into a single stamped check.
Deferred (lazy) subsystem initialization. A container's constructor (container.ts)
builds only what a resolve cannot happen without — registry, scope manager, lifecycle manager, resolver. The inspector,
module tables, scoped/in-flight caches, named/tagged indexes, and class-metadata caches all allocate on first use. An
empty Map isn't free, and most containers never touch most of them. The invariant is that a deferred collaborator
answers identically whether or not it's been touched — pinned by tests/unit/container/deferred-subsystems.test.ts.
Lesson: pay for a subsystem when it's first used, but only if "never allocated" and "allocated but empty" are
indistinguishable to callers.
Ambient context (scoped implicit global). runWithContainer sets and restores a
single module-level activeContainer around a callback, so property-access @inject accessors can find the resolving
container without it being threaded through every signature. Lesson: an ambient is a controlled global — safe when it's
strictly set-around-a-synchronous-callback and always restored.
Narrow callback interface between resolver and context. The three ResolutionContext implementations don't know the
resolver's internals; they call back through a small ResolverCallbacks interface.
Lesson: decouple two collaborators with the smallest interface that carries the messages, not with a shared base
class.
One rule, one place (single source of truth for a decision). The tiered fast lanes above are an optimization risk:
each is a shortcut that must yield exactly what the general path would. So "does this slot match this request?" is
answered by one function, matchesSlot(), and "does this request carry exactly one criterion?" by
singleCriterionOnlyOf() (binding-select.ts,
resolve-options.ts) — the lanes call those rather than re-deciding. This isn't
hypothetical caution: a fast lane that re-implemented a rule once returned a binding a when() predicate was refusing.
Lesson: when several code paths must agree on a decision, put the decision in one function they all call — a duplicated
rule is how the fast path and the slow path silently drift.
B. Design patterns (GoF & idiomatic)
Builder + fluent interface. BindingChain is the canonical example: one object
implements every step interface, registers on to*() via #register, and refines in place via #reslot/#withScope.
Note that refinement re-registers under the original id so id() stays stable across a chain:
#reslot(slot: BindingSlot, predicate: BindingConstraint | undefined): this {
const previous = this.#registered();
this.#binding = createBinding(previous, previous.token, slot, predicate, previous.id);
this.#commit(this.#binding, previous.id);
return this;
}Lesson: a fluent builder can be one mutable object; the interfaces it returns are what make it feel like a pipeline.
Static factory objects. Container (with .create/.fromModules), Module.create,
token(), and tag() are all factories that hide construction and hand back a
typed handle. Lesson: a factory function is the natural home for a brand (below) that a bare constructor can't
produce.
Table-driven strategy. Instead of a switch, the scope-application step uses a Record mapping each BindingScope
to its builder call (APPLY_BINDING_SCOPE). Because it's a total Record, adding a new
scope without handling it is a compile error. Lesson: a lookup table typed as a total Record turns "did I handle
every case?" into a type check.
Null-object / sentinel values. unique symbols stand in for "absent" where undefined/null would be ambiguous:
NO_INSTANCE (unset singleton), SCOPED_MISS
(scope-manager.ts), PLAN_RETRY
(instantiation-plan.ts), UNOWNED_BRANCH. Each lets a cache legitimately
store undefined as a value while still telling "cached undefined" apart from "nothing cached." Lesson: when
undefined is a valid value, reach for a private sentinel, not a second boolean.
Interning / flyweight. tag.ts is the richest small example in the codebase. TagKey.of(value)
returns one shared object per value, so equal criteria compare by ===:
of(value: Value): BindingTag<Value> {
const cacheKey = internKeyFor(value);
const existing = interned.get(cacheKey);
if (existing !== undefined) {
return existing;
}
const pair = { key, value, mask } as BindingTag<Value>;
interned.set(cacheKey, pair);
return pair;
}Lesson: interning turns value-equality into reference-equality, which is both faster and indexable — but read the ±0 split for the correctness subtlety it forces.
Memoization, each with its own invalidation. The engine memoizes in several places, and the interesting part is that each memo has a different invalidation rule matched to what it derives from:
- the resolution
frameon the binding (derives from immutable fields; cleared when a chain rewritesscope); - per-slot frozen
ResolveOptions(resolve-options.ts); - compiled plans by binding id;
- class metadata in
ClassIntrospector.
Lesson: a memo is only as correct as its invalidation; write the invalidation rule from "what does this value depend on?", not from habit.
Inline (one-entry) cache in front of a Map. When a loop asks about the same key repeatedly,
LifecycleManager.activationHandlersFor and
BindingLookupCache.defaultEntry each keep a single
last-token/last-value slot ahead of the map, so the common "same token again" hit skips hashing entirely. Lesson: a
one-entry cache in front of a hash map is nearly free and often wins the actual access pattern.
Object pool. Sync resolution contexts are pooled by depth and reused via reset() rather than allocated per level
(resolver.ts #acquireSyncResolutionContext, context.ts
reset). reset() even compares-before-storing to avoid needless writes — see
write barriers. Lesson: pool the objects on the hottest path, and remember a pooled
object that lives long enough has different GC costs than a fresh one.
Discriminated-union dispatch. Binding is a kind-tagged union (binding.ts); the
instantiation switches exhaustively on kind, and errors form a parallel union keyed by a code literal on the base
DiError. Lesson: a tagged union plus an exhaustive switch is the type-safe alternative to
polymorphism when the set of shapes is closed and hot.
Adapter (output formats). The dependency graph is built once as a neutral JSON, then adapted to Mermaid, DOT,
Cytoscape, and React Flow by small functions in
introspection/graph-adapters/. Lesson: compute the neutral form once,
adapt at the edges.
C. Algorithms & data structures
Cycle detection — four mechanisms, chosen per lane. This is the headline data-structure decision, and a great study in "the right structure depends on the execution model."
- Sync transient-dynamic uses a single boolean,
binding.inFlight. Because synchronous resolution runs on one call stack that can't interleave, the flag is exact membership of the current path — O(1), no allocation. - Everything else sync pushes and pops one shared path array
(
enterResolutionPath), because the error message needs to name the path, which a flag can't. - Async in a cascade reuses the
inFlightflag but clears it when the factory returns its promise (not when it settles) — which is what removes a diamond false-positive. - Async across an await can't rely on a call stack, so it carries an append-only path read by branch depth
(
extendResolutionBranch).
Lesson: don't pick a cycle detector in the abstract — pick the cheapest structure that is exact for the concurrency model of that specific lane.
Threshold-switched linear-scan vs. Set. Within the shared-array detector, membership is an Array.includes scan
while the path is short, and a Set attached to the array (under a symbol key) once it grows past a measured threshold:
export const RESOLUTION_SET_THRESHOLD = 32;
export function enterResolutionPath(resolutionPath: Array<string>, tokenDisplayName: string): Set<string> | undefined {
const pathWithSet = resolutionPath as ResolutionPathWithSet;
let resolutionSet = pathWithSet[RESOLUTION_SET_KEY];
if (resolutionSet !== undefined && resolutionSet.size !== resolutionPath.length) {
resolutionSet = undefined;
pathWithSet[RESOLUTION_SET_KEY] = undefined;
}
if (resolutionSet === undefined && resolutionPath.length >= RESOLUTION_SET_THRESHOLD) {
resolutionSet = new Set<string>(resolutionPath);
pathWithSet[RESOLUTION_SET_KEY] = resolutionSet;
}
if (resolutionSet === undefined ? resolutionPath.includes(tokenDisplayName) : resolutionSet.has(tokenDisplayName)) {
throw new CircularDependencyError([...resolutionPath, tokenDisplayName]);
}
resolutionPath.push(tokenDisplayName);
resolutionSet?.add(tokenDisplayName);
return resolutionSet;
}The two branches answer identically — the threshold switches the data structure, never the behaviour. The size check at the top is what keeps that true: the frames already on the path when the set attaches are handed no set to delete from, so a set whose size disagrees with the path is holding unwound frames' names and gets dropped for the next deep frame to rebuild. (The value 32 is a tuning constant from a depth sweep; it's the kind of number worth re-measuring rather than trusting.) Lesson: for small n a linear scan often beats a hash set; a threshold lets you have both without changing semantics.
Bitmask subset prefilter for tags. Tag matching needs a subset test ("does the request carry every key this slot declares?"). Each tag key is assigned a monotonic id and thus one bit; a key set becomes one machine word:
const mask = (1 << (id % MASK_WIDTH)) as TagKeyMask; // in tag()
export function coversTagKeys(requestMask: TagKeyMask, slotMask: TagKeyMask): boolean {
return (requestMask & slotMask) === slotMask;
}A single & rejects any non-covering slot before a criterion is read. Ids past MASK_WIDTH wrap, so two keys can share
a bit — but that only ever causes a false positive (a slot that passes the prefilter and is then rejected by the exact
identity comparison), never a false negative. See matchesSlot for the
consumer. Lesson: a bitmask turns a set-subset test into one instruction; when bits can collide, design so collisions
cost a re-check, never a wrong answer.
Interning meets a correctness edge: the ±0 split. This is the sharpest "algorithm meets correctness" moment in the
repo. Map keys compare by SameValueZero, under which +0 and -0 are equal; but the tag contract says values compare
by Object.is, under which they aren't. If the intern cache stored both zeros under one key, the two would become
indistinguishable everywhere downstream. So the negative zero is cached under a private symbol instead:
const NEGATIVE_ZERO_KEY: unique symbol = Symbol("di:tag-negative-zero");
function internKeyFor(value: unknown): unknown {
return value === 0 && Object.is(value, -0) ? NEGATIVE_ZERO_KEY : value;
}Pinned by tests/unit/resolution/select/tagged-selection.test.ts. Lesson: Map equality (SameValueZero) and
Object.is differ on exactly one pair of values — if your identity scheme rides on a Map, that difference is a bug
waiting unless you handle it.
Version stamping for cache invalidation. The registry keeps a monotonic #version that bumps on every mutation
(registry.ts); caches stamp themselves with a chainVersion() (the sum of the versions
along the container chain) and self-clear on a mismatch
(binding-lookup-cache.ts). Summing is a cheap way to notice a change
anywhere in the parent chain in one comparison. Lesson: a monotonic version counter is the simplest correct cache key
for "has anything changed since?", and summing along a chain extends it to "has anything changed anywhere above me?".
Iterative alias resolution with exact cycle detection. toAlias bindings are followed in a while loop in
#requireBinding; a lazily-created Set of visited tokens throws
CircularDependencyError rather than overflowing the stack. The cache uses a bounded fold (ALIAS_HOP_LIMIT) as a fast
pre-check and defers to the exact loop past the cap. Lesson: follow a chain iteratively, not recursively, and keep an
exact visited-set for the cycle case even when a cheap bound handles the common case.
DFS for static scope validation. validate() walks the constructor/toResolved
dependency edges depth-first, following aliases to their terminals, and throws ScopeViolationError on a captive
dependency (a longer-lived binding depending on a shorter-lived one). See SPEC — validate.
Lesson: some correctness properties are graph properties; a plain DFS with a visited set is often all you need to check
them ahead of time.
Most-specific-wins arbitration. selectBinding ranks candidates: a lone
predicate-bearing candidate wins; otherwise the lone candidate with the most tags; otherwise it's ambiguous and raises.
Lesson: when several answers match, define specificity explicitly and make ambiguity an error, not a silent pick.
Fixed-arity specialization. Spreading an argument array is not free, so both the interpreter (#resolveDeps) and
the compiler special-case the common small arities — new T(dep0()), new T(dep0(), dep1()), up to three — before
falling back to a spread (instantiation-plan.ts). Lesson: the common
case is usually low-arity; unrolling it a little avoids allocation and helps the JIT.
D. TypeScript techniques
Branded / nominal types. TypeScript is structural, so the codebase manufactures nominal types with phantom brands:
Token<Value>, BindingIdentifier (types.ts), and the tag brands
(tag.ts). The most instructive use is in
resolution-path.ts: OwnedBranchPath and OwnedBranchDepth are brands that
turn "may this async lane append to this array?" into a question the compiler answers — only extendResolutionBranch
can mint one, so a sync frame's plain array simply can't be passed where an owned branch is required. Lesson: a brand
encodes a provenance or a permission the structural type system would otherwise ignore.
Variance annotations (out) — and a deliberate omission. Token, Constructor, and InjectionDescriptor declare
out Value, so the compiler rejects the annotation the day the type stops being covariant — a self-checking assertion.
The binding kinds deliberately carry no variance annotation, which sets up the next trick. Lesson: an explicit out
is documentation the compiler enforces; leaving it off is sometimes just as deliberate.
The method-vs-property bivariance trick. This is the load-bearing type trick of the engine. Under
strictFunctionTypes, function-typed properties are checked contravariantly in their parameters, which would make
Binding<Value> not assignable to the erased Binding the internal lanes pass around. Declaring the lifecycle hooks
as methods instead makes their parameters compare bivariantly, restoring assignability — while the public
ActivationHandler/DeactivationHandler stay function-typed properties so a user's handler is still checked strictly:
interface BindingLifecycleHooks<Value> {
onActivation?(ctx: ResolutionContext, instance: Value): Value | Promise<Value>; // method → bivariant params
onDeactivation?(instance: Value): void | Promise<void>;
}Pinned by tests/types/binding-variance.test.ts. Lesson: method syntax and property syntax have different variance
under strictFunctionTypes — a real tool, not a quirk, when you need the erasure to type-check.
Type-level ordering guarantee. The fluent chain's legal order (Stop 0) is enforced entirely by the return types of
the builder interfaces (binding.ts); a ChainNotRegisteredError only backstops callers who
have no types or cast past them. Pinned by tests/types/container-api.test.ts. Each return type is a state, and the
methods it offers are the only legal transitions out of it:
stateDiagram-v2
[*] --> BindToBuilder: bind(token)
BindToBuilder --> BindingBuilder: to / toDynamic / toResolved
BindToBuilder --> ConstantBuilder: toConstantValue
BindingBuilder --> BindingBuilder: when / whenNamed / whenTagged
BindingBuilder --> Scoped: singleton / transient / scoped
Scoped --> [*]
note right of BindToBuilder
singleton() and when*() are not on this
type yet, so calling one is a compile error
end noteLesson: you can encode a small state machine in return types so illegal transitions don't compile.
satisfies as a completeness guard. createBinding writes one literal that must contain
every field any binding kind declares. It's typed … satisfies ConstructedBindingFields as Binding<Value>, where
ConstructedBindingFields is a Record of every field name — so forgetting one is a compile error, while the fixed key
order (which the single hidden class depends on) is preserved:
return {
kind: fields.kind,
id,
inFlight: false,
frame: undefined,
instance: fields.instance ?? NO_INSTANCE,
// …every other field, in a fixed order…
} satisfies ConstructedBindingFields as Binding<Value>;Lesson: satisfies checks a value against a type without widening it — here it turns "did I write every field?" into a
compile error.
Advanced conditional & mapped types. A few worth reading: DistributiveOmit/KeysOfUnion driving PartialBinding
(binding.ts); ResolvedDependencyValue decoding multi/optional flags into Array<T> /
T | undefined (descriptor.ts); and toResolved's mapped tuple with a const type
parameter plus NoInfer to type factory arguments positionally against their declared dependencies. Lesson: mapped
tuples plus NoInfer let a factory's argument types be derived from a dependency list rather than restated.
Type predicates. Small narrowing helpers — isInjectionDescriptor, isSyncModule,
#isPlainConstant — give the hot paths a typed shortcut and keep the "which shape is this?" logic in one named place.
Lesson: a x is T predicate is how you turn a runtime shape check into type information.
Conditional package.json#imports. Internal #/… specifiers resolve to src/ during development and to built
dist/ for consumers, via conditional import maps (package.json) — no tsconfig path aliases needed.
This is a packaging technique as much as a TS one; the root CLAUDE.md explains the three-audience
reasoning. Lesson: the imports/exports fields can serve dev and published consumers different files under one
specifier.
Symbol-keyed off-band data. Non-enumerable symbol properties attach engine bookkeeping to public objects without it
showing up in spreads or JSON: MEMOIZED_RESOLVE_OPTIONS (resolve-options.ts),
CONSTRAINT_REQUIREMENT, RESOLUTION_SET_KEY. The "spread doesn't copy symbol keys" property is even relied on for
correctness — the escape thunk's [...names] intentionally drops a stale membership Set that lives under a symbol.
Lesson: a symbol key is private-by-convention storage that survives on the object but stays invisible to spreads and
serialization — occasionally that invisibility is the feature.
E. Performance engineering techniques
Reminder: the following are techniques and the reasoning behind them, not benchmark results. Whether any of them is
worth it today is an empirical question the benchmarks/di-inversify suite
answers; the ARCHITECTURE.md notes carry the design rationale.
One V8 hidden class for every binding. V8 gives objects with the same properties in the same order a shared "hidden
class," and monomorphic property reads (always the same hidden class) are much cheaper than megamorphic ones. Every
binding is built by the one literal in createBinding with a fixed field order, and the registry
stores it by reference rather than re-copying — so the resolver's hot kind/scope/factory reads stay monomorphic.
Lesson: if a hot object type has many instances read on a fast path, build them all one way.
Totalizing a field to avoid a branch. Even an alias binding, which has no scope of its own, declares
scope: "transient" — so scope is always a field read and never needs an undefined fallback. The named
effectiveBindingScope helper is kept only because it's the vocabulary validation and introspection speak. Lesson:
making an optional field total can remove a branch (and keep the hidden class stable) at the cost of a tiny redundancy.
Write-barrier-aware reset. A pooled resolution context lives long enough to sit in V8's old space, where every
pointer store into it costs a write barrier. reset() (context.ts) therefore
compares-before-storing the resolver and the two scratch arrays, and the resolver hands it the same array pair each
time so the compares actually hit. Lesson: for a long-lived object, an unnecessary pointer write isn't free; comparing
first can be cheaper than storing.
Allocation avoidance on the hot path. Several shapes exist to not allocate: the singleton stored on
binding.instance (a field, not a Map entry); shared ROOT_CONSTRAINT_CONTEXT/EMPTY_* constants for the root case;
one frozen ResolveOptions per slot reused across every resolve; one AsyncCascadeContext shared across all levels of
a cascade; and a deliberately non-async helper to avoid a promise + state machine per level
(resolver.ts). Lesson: the cheapest allocation is the one you don't make; look for
per-call objects that could be per-slot, per-container, or constant.
getOrInsert vs getOrInsertComputed, chosen by hit rate. The package's own Map upsert helpers
(map-upsert.ts) come in eager and lazy forms — its own, because the ES2025 methods they
stand in for would raise the package's Node floor. The registry's index insertions use the eager getOrInsert because a
bind is usually a token's first (the fallback value is usually what gets stored); taggedEntry() uses the lazy
getOrInsertComputed with a module-scope factory so no closure is allocated on the common hit
(registry.ts, binding-lookup-cache.ts).
Lesson: eager-vs-lazy isn't a style choice; pick it from which branch dominates.
Dispatcher ordering (test under the branch that implies it). In #resolveDefaultEntry
the plain-constant test lives inside the singleton branch — because a constant is a singleton — rather than at the
top of the dispatcher. Hoisting it up would charge every transient resolve for a test it never needs. The same function
is noted as inlining-sensitive: a test added inside a branch it doesn't even take has shifted an unrelated row. Lesson:
put a check under the branch that already implies it, and treat hot dispatchers as inlining-sensitive — measure edits
near them.
GC-friendly weak caches. Per-class and per-reader caches use WeakMap/WeakSet
(class-introspector.ts), so a class or reader that becomes unreachable
takes its cache entry with it. Lesson: key a cache weakly when its lifetime should follow the key's, not the cache's.
Cheap negative-answer flags. A container that never bound a constant sets hasHeldConstantBinding to skip the
constant-deactivation sweep at dispose; activationVersion === 0 short-circuits all activation-hook checks. A single
boolean lets a whole sweep be skipped. Lesson: a one-time "there is nothing here" flag can save a repeated scan for the
common empty case.
F. Testing techniques
di's tests live under tests/unit, tests/integration, and tests/types (the repo-wide taxonomy is described in
TESTING.md). The techniques worth learning from:
Type-level tests with expectTypeOf. The load-bearing type invariants are pinned by compile-time assertions, not
runtime ones: tests/types/binding-variance.test.ts (the method-vs-property trick),
tests/types/async-branch-ownership.test.ts (the ownership brands), tests/types/container-api.test.ts (the fluent
order). A type test that stops compiling is the failure signal. Lesson: if an invariant is a type property, assert it
in the type system — a runtime test can't see it.
Invariant-pinning tests, named next to the invariant. Each correctness invariant in
ARCHITECTURE.md cites the test that holds it — e.g.
tests/unit/resolution/in-flight-invariants.test.ts (the cycle flag is released on every exit path),
tests/unit/resolution/cache-invalidation.test.ts (memos clear correctly across lanes),
tests/unit/resolution/singleton-on-binding.test.ts. Lesson: pin a subtle invariant with a test whose name states the
invariant, so a failure reads as "you broke X," not "assertion failed."
Structural (not timing) assertions via a diagnostics seam. Performance shapes are verified without a benchmark: a
private RESOLUTION_DIAGNOSTICS symbol (diagnostics.ts) exposes counters like
compiledPlanCount, syncContextPoolSize, and builtSubsystems, so a unit test can assert "a plan was compiled" or
"this subsystem stayed deferred" deterministically. Lesson: you can test that an optimization is active (a
structural fact) even when you can't test that it's fast (a flaky, machine-dependent fact).
Toggle-then-re-resolve for state cleanup. To prove a failure path released its state (e.g. inFlight), a test flips
a let flag to make the first resolve throw, then re-resolves and asserts success — proving nothing leaked
(tests/unit/resolution/in-flight-invariants.test.ts). Lesson: to test that cleanup happened, force the failure, then
exercise the thing again.
Where to go next
A reading order that tends to work:
- Use it —
README.md, and the runnableexamples/. - Take the tour above, then open each file it links as you go.
- Study one catalogue theme end-to-end. The most self-contained single files to start from:
src/core/tag.ts(interning + bitmask + the ±0 split),src/resolution/path/resolution-path.ts(scan-vs-Set + ownership brands), andsrc/injection/resolve-options.ts(memoize + freeze). - Read the contract —
SPEC.md— when you need the exact rules, andARCHITECTURE.mdwhen you're about to change the engine and want the invariants and the reasoning. - Run the tests — they're the executable version of every claim here, and the type tests under
tests/typesare short and very readable.
If a performance claim in this document matters to a decision you're making, don't take it on faith — the
benchmarks/di-inversify suite is how you check it against your own runtime.
License
Released under the MIT License.