Mental model. A binding tells the container how to produce a value for a token. It has four parts, declared in the
fixed chain order to*() → when*() → scope() → on*(): a strategy (class, constant, factory, alias), optional
selection criteria (a slot and/or a predicate), a scope (how long an instance lives), and optional lifecycle
hooks.
Binding kinds
| Method | InversifyJS v8 equivalent | When to use it |
|---|---|---|
.to(Class) |
.to(Class) |
The container news it and injects deps |
.toSelf() |
.toSelf() |
The token is the class |
.toConstantValue(value) |
.toConstantValue(value) |
A constant — config, primitive |
.toDynamic(ctx => ...) |
.toDynamicValue(ctx => ...) |
Sync factory using ctx.resolve() |
.toDynamicAsync(ctx => Promise) |
(uses toDynamicValue async) |
I/O at construction time |
.toResolved(factory, deps) |
.toResolvedValue(factory, deps) |
Explicit sync deps, no ctx needed |
.toResolvedAsync(asyncFactory, deps) |
— | Explicit async deps, no ctx needed |
.toAlias(otherToken) |
.toService(otherId) |
Alias this token → another token |
toDynamicvstoDynamicAsync.toDynamicforces the factory to returnValue(never aPromise);toDynamicAsyncforces it to returnPromise<Value>. The compiler then enforcesresolveAsync()where it is needed. Compatibility: InversifyJS v8 usestoDynamicValuefor both sync and async factories — the compiler enforces nothing.
toResolvedvstoResolvedAsync.toResolvedis shorthand fortoDynamicwhen the deps are simple and the factory is sync.toResolvedAsyncis shorthand fortoDynamicAsyncwhen the deps are simple but the factory needs to be async (initialising a cache from config, say). Both are pure syntactic sugar — they add no capability overtoDynamic/toDynamicAsync.
toAliaschains. An alias may point at another alias — the container follows the chain to the final binding. A cycle (A → B → A) is detected and throwsCircularDependencyError.toAliasreturns anAliasBindingBuilderso it can carry constraints and.id()— the only builder with no value type parameter, because an alias produces no value of its own; it keeps theNamesparameter so itswhenNamed()stays checked.
Scope
Mental model. Scope decides how many instances a binding produces and who owns them: one per container hierarchy
(singleton), one per child container (scoped), or one per resolve (transient).
.singleton() // ←→ .inSingletonScope() — created once, reused forever
.transient() // ←→ .inTransientScope() — every resolve = new (the default if unspecified)
.scoped() // ←→ .inRequestScope() — once per child containerNormative.
- Scope always comes after
when*in the chain (Fluent chain).- The default when no scope is declared is
transient.- The
on*()lifecycle hooks are only available afterscope()is called explicitly. If you do not need lifecycle hooks, you can skipscope()and take the transient default.- A
scopedbinding is only a singleton within the child container that first resolves it. Resolvingscopeddirectly from a parent container (with no child scope context) throwsMissingScopeContextError.- Singleton cache ownership: a singleton is cached at the container where the binding is defined — not at the container that called resolve. When
child.resolve(SomeToken)walks up to the parent and finds a singleton binding there, the instance is cached at the parent.child.dispose()only deactivates singletons defined at the child.
Scope validation matrix — captive dependency. A captive dependency is a long-lived consumer holding a shorter-lived dependency, which silently freezes that dependency for the consumer's whole life.
| Consumer ╲ Dependency | singleton |
scoped |
transient |
|---|---|---|---|
singleton |
✅ OK | ❌ Violation | ❌ Violation |
scoped |
✅ OK | ✅ OK | ✅ OK |
transient |
✅ OK | ✅ OK | ✅ OK |
container.validate() walks the whole dependency graph and throws ScopeViolationError for any violation. See
validate for the limits of validate().
Rationale — why
transientis the default. It is the safest row of the matrix — atransientconsumer may depend on any scope without a captive dependency, so the default can never introduce a violation on its own. It is also a fixed constant inlined at eachto*(), never a container-level setting:bind(X).to(Y)means the same thing in every file, which keeps with the no-hidden-behaviour principle. Reach forsingleton()orscoped()explicitly the moment a binding needs shared state or a lifecycle.
toConstantValue — semantics
toConstantValue(value) creates a binding that always returns the same value. It is treated as a singleton — there is
no scope choice.
Normative — lifecycle of a constant.
onActivationmay be registered and will be called the first time the value is resolved. The post-activation result is cached; activation does not run again on later resolves.- If
onActivationreturns aPromise, the resolve must useresolveAsync().onDeactivationmay be registered and will be called when the binding is unbound or the container is disposed.- The original value is considered immutable —
onActivationmay return a Proxy wrapper. After activation, the cached value is the activation result, not the original.
Constraints — when*
Mental model. A constraint decides when a binding is eligible for a request. There are two mechanisms:
- Slot criteria —
whenNamed,whenTagged,whenDefault. Static, declared at bind time, matched at constant cost against the request's hint. They define the binding's slot (Slots and last-wins). - Predicates —
when(ctx => boolean). Dynamic, evaluated at resolve time against theConstraintContext, after slot matching.
when* comes before to*(), which registers the binding in the slot the constraints declare. A binding may carry one
or several combined constraints.
// Named binding
container.bind(Logger).whenNamed("console").to(ConsoleLogger).singleton();
container.bind(Logger).whenNamed("file").to(FileLogger).singleton();
// Tagged binding — a criterion can only be minted from a tag key, never by hand
const Fuel = tag<"petrol" | "electric">("app:fuel");
const Size = tag<"v8" | "v6">("app:size");
container.bind(Engine).whenTagged(Fuel.of("petrol")).to(PetrolEngine);
container.bind(Engine).whenTagged(Fuel.of("electric")).to(ElectricEngine);
// Several tags on one binding — a specialisation of the petrol binding above. The hint
// {fuel:petrol} gets PetrolEngine; the hint {fuel:petrol, size:v8} gets TurboV8 because it
// declares more tags, i.e. it is more specific.
container.bind(Engine).whenTagged(Fuel.of("petrol")).whenTagged(Size.of("v8")).to(TurboV8);
// Explicit default slot — matches when there is no name and no tag
container.bind(Logger).whenDefault().to(NoopLogger);
// Custom predicate — uses ConstraintContext
container
.bind(Logger)
.when((ctx) => ctx.ancestors.some((f) => f.tokenName === "DebugModule"))
.to(VerboseLogger);
// Combining a name with a custom predicate on one binding
container
.bind(Logger)
.whenNamed("audit")
.when((ctx) => ctx.parent?.scope === "singleton")
.to(AuditLogger);Slot criteria — the three verbs:
whenTaggedtakes a criterion, not a loose pair. A criterion can only be minted byTagKey.of(), so the key must be declared up front withtag<Value>(name)— that is what makes identity comparison enough to stand in forObject.is(ResolveOptions). The key name is still astring, so it follows the display-name convention (Display names):tag("mylib:fuel"),tag("@scope/pkg:env").
whenNamedis sugar. A name is a criterion of the reserved keyslotName—whenNamed("console")≡whenTagged(slotName.of("console")), single-valued per slot (Slots and last-wins).
Explicit
whenDefault()vs declaring no constraint. A binding with nowhen*at all also matches the default slot.whenDefault()is useful when you want to document the intent explicitly, or to combine it with a customwhen(). It declares the slot a fresh binding already holds, so it changes nothing — in particular it does not clear a name or tag an earlierwhenNamed()/whenTagged()in the same chain set.
Predicates — when():
Normative — rules for a
when()predicate.
- The predicate must be pure and deterministic — no side effects, no I/O. Breaking this rule is undefined behaviour and may cause an infinite loop or incorrect caching.
- Because it is pure, the engine may evaluate it once per container state and reuse the answer where the context cannot differ: a root-level
resolveAllwith no options keeps its candidate list until any registry in the chain changes, and keeps the value list too while every member is a hook-free constant or a hook-free singleton whose instance is cached. A read carrying options, or made from inside a factory, evaluates every predicate afresh.- The predicate must not call
ctx.resolve*()— that causes circular resolution.
Performance note. For a
transientbinding on a hot path (resolved on every request), a complexwhen()predicate is called a great many times. PreferwhenNamed/whenTagged(O(1) lookup) on hot paths; keep customwhen()predicates for configuration-time bindings.
Resolving with a hint:
const Env = tag<"production" | "staging">("app:env");
// Named
container.resolve(Logger, { name: "file" });
// One tag — `tag` is shorthand for exactly one criterion
container.resolve(Engine, { tag: Fuel.of("electric") });
// Several tags — the request must name every tag the binding declares
container.resolve(Engine, { tags: [Fuel.of("petrol"), Size.of("v8")] });
// Name and tag combined
container.resolve(Logger, { name: "audit", tag: Env.of("production") });toAlias — hint forwarding
An alias points at another token. When the alias is resolved, the hint is forwarded to the target token's resolution.
Normative — a default-slot alias is transparent. An alias with no slot, predicate or membership of its own answers a request whose criteria no slot of its token matches, and forwards those criteria to its target unchanged. An exact slot on the alias's own token is tried first and still wins, and the nearest container that can answer does, as for any binding. This holds for
resolve,resolveOptionaland their async forms, andhasagrees with them;resolveAllfilters the alias's token by its own slots, so a default-slot alias does not join a collection read that carries criteria.
container.bind(Logger).whenNamed("console").to(ConsoleLogger).singleton();
container.bind(Logger).whenNamed("file").to(FileLogger).singleton();
container.bind(AbstractLogger).toAlias(Logger);
// The hint is forwarded to the Logger resolution
const fileLogger = container.resolve(AbstractLogger, { name: "file" });
// → FileLogger (the hint { name: "file" } is forwarded to Logger)If the alias carries its own constraint (whenNamed("audit")), that constraint is used to select the alias binding;
it does not affect what gets forwarded:
container.bind(AbstractAuditLogger).whenNamed("audit").toAlias(Logger);
// This binding is only selected when resolving AbstractAuditLogger with the hint { name: "audit" }
// Once selected, the hint { name: "audit" } is forwarded to the Logger resolution
const logger = container.resolve(AbstractAuditLogger, { name: "audit" });
// → the Logger binding matching { name: "audit" }; with none, NoMatchingBindingError names LoggerAn alias has no scope of its own. The scope is decided by the target binding. An alias is only a pointer — it caches no instance.
A dangling alias is a miss, not an error. Because an alias is transparent, an optional or collection read whose alias chain ends at a token nothing matches answers
undefined/ skips the member, exactly as it would for the target directly; only a requiredresolve/resolveAsyncthrows. An alias cycle always throwsCircularDependencyError.
Builder type interfaces
Mental model. Each step in the chain returns a different builder, and it is precisely that builder's method set which enforces the order in Fluent chain. Reading the table row by row tells you what you may call next.
| Builder returned by | Constraint | Scope | onActivation |
onDeactivation |
id() |
|---|---|---|---|---|---|
bind(token) |
— | — | — | — | — |
to*() |
✅ | ✅ | — | — | ✅ |
toConstantValue() |
✅ | — | ✅ | ✅ | ✅ |
toAlias() |
✅ | — | — | — | ✅ |
singleton() |
— | — | ✅ | ✅ | ✅ |
transient() |
— | — | ✅ | — | ✅ |
scoped() |
— | — | ✅ | — | ✅ |
How to read the rows:
bind(token)returns a builder with only theto*group and nothing else.- The shared part — the four constraint methods (
when,whenNamed,whenTagged,whenDefault) plusmany()andid()— is factored into aSlotConstrainedBuilderinterface that the three concrete builders inherit. It never appears in the chain, and no call returns it. toConstantValue()has no scope step because a constant binding is always a singleton. Calling a lifecycle hook on it moves to a builder with only lifecycle andid()left — a one-way state: calling a hook locks the constraint part.toAlias()is the only builder without a value type parameter — an alias produces no value of its own, so there is nothing to infer. It still carries the token's slot names, sowhenNamedstays checked.transient()has noonDeactivationbecause a transient instance is never deactivated;scoped()keeps it, since a child container deactivates what it cached (ActivationHandlerandDeactivationHandler).
Exact shape:
src/core/binding-builders.ts—BindToBuilder,SlotConstrainedBuilder,BindingBuilder,ConstantBindingBuilder,AliasBindingBuilder,SingletonBindingBuilder,TransientBindingBuilder,ScopedBindingBuilder,SingletonLifecycleBuilder.
Normative — a repeated
on*()on one chain replaces the hook it already carries. The three chain verbs compose three different ways:
when()narrows — a candidate passes every predicate.- Container-level hooks accumulate — each registration is another listener.
- A chain's
onActivation/onDeactivationreplaces — a chain held in a variable is a reconfiguration handle, and re-calling its lifecycle verb means "this hook now", not "this hook too".A caller who wants several activation steps composes them in one handler or registers container-level hooks. Pinned by
tests/unit/resolution/cache-invalidation.test.ts("drops a hook that was replaced on the same chain"); changing this to accumulate is a behavior change, not a clarification.
Rationale — why
BindingBuilderhas noon*(). Lifecycle hooks need the scope context to have clear semantics:onDeactivationonly makes sense for an instance something owns — a singleton or a scoped one — whileonActivationon a transient fires every time a new instance is created. Forcing scope to be declared before lifecycle removes the ambiguity entirely — the compiler will not let you confuse them.
ConstantBindingBuilder.onActivation→SingletonLifecycleBuilder. AfteronActivation()oronDeactivation()is called, the builder no longer exposeswhen*— a one-way state: calling lifecycle "locks" the constraint and moves into the lifecycle phase.
toResolved and toResolvedAsync — explicit deps
// toDynamic — use it when the logic is complex or the resolve is conditional
container.bind(App).toDynamic((ctx) => {
const logger = ctx.resolve(Logger);
const config = ctx.resolve(Config);
return new App(logger, config);
});
// toResolved — deps declared explicitly, the factory receives the right types
container.bind(App).toResolved(
(logger, config) => new App(logger, config),
[Logger, Config] as const, // `as const` is required — TypeScript infers a tuple, not a union
);
// toResolvedAsync — explicit deps, async factory
container.bind(Cache).toResolvedAsync(async (config) => Cache.connect(config.redisUrl), [Config] as const);With deps: [Logger, Config] as const, TypeScript infers the factory params as [LoggerService, AppConfig] — no manual
annotation needed.
toResolved/toResolvedAsyncand named/tagged deps. Each element ofdepsis anInjectableDependency: a plain token, or a descriptor frominject(token, { name, tag, tags }),optional(token, ...)orinjectAll(token, ...)— the same forms@injectable([...])takes. A named, tagged, optional or collection dependency is declared there directly;toDynamic/toDynamicAsyncis only needed when the resolve itself is conditional.
container
.bind(Audit)
.toResolved((logger, plugins) => new Audit(logger, plugins), [
inject(Logger, { name: "file" }),
injectAll(Plugin),
] as const);BindingIdentifier — precise unbinding
The builder has .id() to obtain a BindingIdentifier — used to unbind one specific binding out of several:
const consoleId = container.bind(Logger).whenNamed("console").to(ConsoleLogger).singleton().id();
const fileId = container.bind(Logger).whenNamed("file").to(FileLogger).singleton().id();
// Unbind only the "console" binding — "file" is untouched
container.unbind(consoleId);
.id()and chain order..id()may be called at any step afterto*(). The builder can keep chaining afterwards —.id()is not terminal. The id is stable for the whole chain: a value taken right afterto*()still points at the binding once its scope and hooks are set.
Lifecycle hooks
onActivation runs after @postConstruct(), before the instance is cached into its scope. It must return an instance.
onDeactivation is available on singleton, scoped and toConstantValue, never on transient — enforced at compile
time by the builder type. The container-level container.onDeactivation(token, handler) takes any token, so the type
gate does not apply there; validate() reports an UnreachableLifecycleHookError when such a hook is keyed to a token
whose every binding is transient (nothing it could ever deactivate). An onActivation hook stays valid on any scope.
container
.bind(Database)
.to(PostgresDatabase)
.singleton()
.onActivation(async (ctx, db) => {
await db.connect();
return db; // must return — may return a Proxy wrapper
})
.onDeactivation(async (db) => {
await db.disconnect();
});The full lifecycle order. One resolve has two phases — construction (everything inside the single new) and
activation (everything the resolver does once the instance exists). Deactivation runs the activation steps in reverse.
Construction (within one `new`, usually wrapped in `runWithContainer` when the class has @inject accessors):
1. Accessor initializers — property injection via @inject accessor, run as the class's fields initialize
2. Constructor body — already sees every injected accessor field
Activation (after the instance exists):
3. @postConstruct() — LifecycleManager (sync/async depending on the resolve path)
4. per-binding onActivation()
5. container-level onActivation()
Deactivation (reverse):
1. container-level onDeactivation()
2. per-binding onDeactivation()
3. @preDestroy() — every method, in declaration order; across an inheritance chain, the derived class's before the base'sStep by step:
- Accessor initializers — an
@inject accessoris a field, so its initializer runs when the class's fields are initialized: at the start of the constructor for a base class, right aftersuper()for a derived one. Every injected field is set before the constructor body runs. - Constructor body — the class's own code, which can already read the injected accessor fields.
@postConstruct()— the resolver calls it oncenewhas returned; across an inheritance chain, the base class's methods run before the derived class's.- Per-binding
onActivation— may wrap the instance; its return value is what proceeds. - Container-level
onActivation— runs last, over the value returned by step 4.
In short: accessor initializers (@inject accessor) → constructor body → @postConstruct() → onActivation. The
constructor body and @postConstruct() both run after the accessor fields have been injected.
@postConstruct/@preDestroyrequire a public, string-named method. The lifecycle reader calls a hook by name on the instance, so a symbol-keyed or private method cannot be found again. TypeScript rejects both at the decoration site; untyped code throwsSymbolKeyedLifecycleErrororPrivateLifecycleMethodErrorat the declaration, rather than a misleading metadata error at resolve.
Type inference — no annotation needed:
// InversifyJS v8 — must be annotated by hand
.onActivation((_ctx: ResolutionContext, db: Database) => { ... })
// This library — the compiler infers from the binding
container.bind(Database).to(PostgresDatabase)
.singleton()
.onActivation((ctx, db) => {
// ^? PostgresDatabase
return db;
});Full examples
// Class binding
container.bind(Logger).to(ConsoleLogger).singleton();
// Self binding
container.bind(ConsoleLogger).toSelf().singleton();
// Constant value
container.bind(Config).toConstantValue({
port: 3000,
env: "production",
dbUrl: "postgres://localhost/app",
redisUrl: "redis://localhost",
});
// Named bindings
container.bind(Logger).whenNamed("console").to(ConsoleLogger).singleton();
container.bind(Logger).whenNamed("file").to(FileLogger).singleton();
// Tagged binding
container.bind(Engine).whenTagged(Fuel.of("petrol")).to(PetrolEngine);
container.bind(Engine).whenTagged(Fuel.of("electric")).to(ElectricEngine);
container.bind(Engine).whenTagged(Fuel.of("petrol")).whenTagged(Size.of("v8")).to(TurboV8);
// Sync dynamic factory
container
.bind(App)
.toDynamic((ctx) => new App(ctx.resolve(Logger), ctx.resolve(Config)))
.singleton();
// Async factory
container
.bind(Database)
.toDynamicAsync(async (ctx) => {
const config = ctx.resolve(Config);
const db = new PostgresDatabase(config.dbUrl);
await db.connect();
return db;
})
.singleton()
.onDeactivation(async (db) => db.disconnect());
// Resolved sync — explicit deps
container
.bind(Mailer)
.toResolved((logger, config) => new Mailer(logger, config), [Logger, Config] as const)
.singleton();
// Resolved async — explicit deps
container
.bind(Cache)
.toResolvedAsync(async (config) => Cache.connect(config.redisUrl), [Config] as const)
.singleton()
.onDeactivation(async (cache) => cache.close());
// Alias
container.bind(AbstractLogger).toAlias(Logger);
container.bind(AbstractAuditLogger).whenNamed("audit").toAlias(Logger);Slots and last-wins — the exact definition
Mental model. A slot is the set of conditions a binding declares about the request that may select it — its name, its tags, or nothing at all. The registry uses the slot as the key for last-wins: two bindings of one token with the same slot replace each other; different slots coexist. At resolve time a slot matches when the request states every condition the slot declares. The slot with no conditions is the default slot.
Vocabulary
Normative —
BindingSlot. A binding slot is the key that uniquely identifies a slot in the registry — the binding's criterion set, computed from its constraints:BindingSlot = { tags: ReadonlyArray<BindingTag>, // from EVERY whenTagged(), plus slotName.of(n) when the binding declares whenNamed(n) name: string | undefined, // derived view: the reserved criterion's value, undefined when the slot carries none keyMask: TagKeyMask, // derived view: one bit per tag key present, for a cheap first test }
tagsholds each criterion once, one per key, and is compared as a set: order carries no meaning.
Normative — a name is a criterion. The package exports a reserved tag key
slotName: TagKey<string>, and a name is a criterion of that key. One selection model covers names and tags alike:
whenNamed(n)≡whenTagged(slotName.of(n))— the binding-side sugar.whenParentTagged(slotName.of(n))is the token-free ancestor spelling;whenParentNamed(T, n)adds the token check (Advanced Constraints).{ name: n }inResolveOptions/InjectOptions≡{ tag: slotName.of(n) }— the request-side sugar (ResolveOptions).- One criterion per key, reserved key included: a slot carries at most one criterion of any key — re-declaring a key, through either verb, replaces that key's criterion.
whenNamedinherits this rule rather than adding one.- What reserves the key is its identity, not its display name. Diagnostics render its criterion as
name:<value>, nevertag:…, andBindingSlot.nameis the derived view of it thatResolutionFrame.slot(ConstraintContext) and thewhen*Namedconstraints read.- A tag key types its values; the reserved key is shared, so a name's values are typed by the token instead:
Token<Value, Names>narrowswhenNamed, every request-sidename, and thenameofwhenParentNamed(T, name)/whenAnyAncestorNamed(T, name)to theNamesof the token they name (Token API). A name is a label on one token's slots; a label shared across tokens is what a tag key is for.
Normative — slot equality. Two binding slots are equal when their criterion sets are equal by the identity of each criterion (order does not matter). Because criteria are interned (
ResolveOptions), identity here gives exactly the result ofObject.ison[key, value]. Thedefaultslot is the empty criterion set.
Normative — predicate-only
when(). A binding carrying only.when(predicate)(with nowhenNamed/whenTagged) does not take part in slot last-wins — several bindings for one token can coexist with the same binding slot. If ≥ 2 candidates remain after runtime filtering,resolve/resolveAsyncthrowsAmbiguousBindingError(notInternalError— this is a user error, not an internal one).
Normative — collection members:
many(). A binding declared with.many()is a collection member: several members of one token coexist on the default slot,resolveAll/resolveAllAsyncreturn every member (plus whatever else the request matches, in registration order), andresolve/resolveAsyncnever select a member — a token holding only members has nothing a single resolve can select, soresolvethrowsNoMatchingBindingError(the token is bound, just not on a slot the request can pick) andresolveOptionalanswersundefined. A member takes no part in slot last-wins: it neither displaces nor is displaced by the ordinary default binding or by other members. A member keeps the default slot —many()on a named or tagged binding, orwhenNamed/whenTaggedon a member, throwsManyBindingSlotError— and may carrywhen()predicates, which apply as for any candidate. This is the intended form of a strategy set; a predicate that always passes is not.
Candidate: a binding whose slot matches the request's criterion set and that passes every when(ctx) predicate.
The matching rule
Normative — filtering
ResolveOptions→ slot. One rule, whatever mix of spellings the request uses:
- The request's criterion set is the union of
tags,tag, and — whennameis present —slotName.of(name)(ResolveOptions);tags: []counts as no criteria.- A slot matches when every criterion it declares is in the request's criterion set — a superset filter. Adding a criterion to the request makes it match more, not fewer.
- The default slot is the one exception: a slot with no criteria matches only a request with no criteria — a request carrying any criterion never falls back to the default slot.
- A slot that declares no name states no condition on the name — it does not demand the request drop its
name, exactly as a slot withoutsizedoes not demand the request dropsize.- Criteria compare by identity —
Object.ison[key, value]— and predicates are evaluated after slot matching.
| Request | Slot {} |
Slot {name:x} |
Slot {fuel:petrol} |
Slot {name:x, fuel:petrol} |
|---|---|---|---|---|
{name:"x"} |
✗ | ✓ | ✗ | ✗ |
{tags:[fuel:petrol]} |
✗ | ✗ | ✓ | ✗ |
{name:"x", tags:[fuel:petrol]} |
✗ | ✓ | ✓ | ✓ |
No criteria — resolve and resolveAll differ
Normative. When
ResolveOptionsis absent or carries no criteria,resolve/resolveOptionalread that as a request for the default slot exactly, so a binding with only a named/tagged slot is not selected.resolveAllinstead takes every binding of the token, named and tagged included.
Case table
| # | Case | Resulting slot | resolve with no hint |
resolveAll / hint |
|---|---|---|---|---|
| 1 | bind(T).to*(A) |
Default | A | [A] |
| 2 | bind(T).to*(A) then bind(T).to*(B) |
Default last-wins | B | [B] |
| 3 | whenNamed("a").to*(A) then whenNamed("a").to*(B) |
Named "a" last-wins | NoMatchingBindingError (no default) |
Hint {name:"a"} → B |
| 4 | whenNamed("a").to*(A) and whenNamed("b").to*(B) |
Named "a" + Named "b" | NoMatchingBindingError |
resolveAll → [A, B] |
| 5 | to*(A) and whenNamed("x").to*(B) |
Default + Named "x" | A | resolveAll → [A, B] |
| 6 | rebind(T).to*(C) |
Explicit reset | C | [C] |
| 7 | Tags {fuel:petrol, size:v8}.to*(A) then the same tags .to*(B) |
Tag-set last-wins | Hint {tags:[...]} → B |
Hint → B |
| 8 | Tags {fuel:petrol}.to*(A) and tags {fuel:petrol, size:v8}.to*(B) |
Two different tag-sets | Hint {tags:[fuel]} → A; hint {tags:[fuel, size]} → B (more specific) |
resolveAll → [A, B] |
| 9 | Tags {fuel:petrol}.to*(A) and named "x" + tags {fuel:petrol}.to*(B) |
Tagged + named-tagged | NoMatchingBindingError (no default) |
Hint {tags:[fuel]} → A; hint {name:"x", tags:[fuel]} → B (A matches too; B more specific) |
Row 3 — resolve with no hint throws NoMatchingBindingError (not TokenNotBoundError) because the token has
bindings but no slot matches the empty hint. The message lists the available slots — "Available slots: [name:a]" here,
since B replaced A on the one slot both declared; row 4 lists [name:a, name:b].
Normative — a displaced binding still deactivates. When a plain last-wins
bind()(rows 2, 3, 7) displaces a binding that owns a deactivation — asingletonortoConstantValuewith anonDeactivationhook — the displaced binding leaves the selection but its instance is still torn down — atdispose(), or earlier when its id is unbound (unbindand singleton deactivation) or the module that registered it is unloaded (unloadand cached singletons). Last-wins changes which binding answers a resolve; it does not silently drop a lifecycle the container still owes. The same holds when the displacing bind runs inside a module load —Module.createsetup or a declared module alike — and unloading that module does not restore the displaced binding: it stays out of the selection, and its deactivation stays owed.
Rows 8 and 9 — a more detailed hint satisfies more bindings, hence the need for a tie-breaker. A binding's criteria
are its conditions, not a filter that must match exactly. In row 8 the hint {fuel:petrol} rules out B because B
also demands size; the hint {fuel:petrol, size:v8} satisfies both A and B, because A's only condition is stated
too. Row 9 is the same shape with the name as the extra criterion: {name:"x", tags:[fuel]} satisfies A — whose only
condition, fuel, is stated — and B, which states both; B wins on specificity. This is a dispatch model (like routing,
media queries, overload resolution), and every dispatch model needs a tie-breaker.
The more-specific rule
Normative — for
resolve/resolveOptional. Applied in order, stopping at the first step that picks exactly one candidate:
- Predicate: if exactly one candidate carries a
when()predicate, that candidate wins. With two or more, this step decides nothing and the next one weighs every candidate.- Criterion count: the candidate declaring more criteria than every other candidate wins — it matches more of what was asked. A name, when the slot carries one, counts as one criterion like any other.
- If no step decides, throw
AmbiguousBindingError.
So row 8 resolves in both directions: {fuel} → A, {fuel, size} → B. An equal criterion count is still ambiguous —
{fuel:petrol}.to*(A) and {size:v8}.to*(B) with a hint carrying both tags leaves neither more specific.
resolveAll does not apply this rule: it returns every matching candidate, and specificity only comes into play
when exactly one must be chosen.
Normative — the more-specific rule is container-local. Selection answers from the nearest container whose candidates match before consulting the parent. A child's matching subset slot (say, tag-only) therefore answers a
{name, tags}request even when the parent declares a slot carrying more of its criteria — locality outranks specificity across the chain.
has(token)and slot semantics.container.has(token)returnstrueif the token has any binding at all (even if only named/tagged slots, with no default).container.resolve(token)with no hint can still throwNoMatchingBindingErroreven whenhas(token)istrue. See Introspection for the right way to usehas+hasOwn.
Compatibility — the one-rule model vs. the earlier two-rule model. Under the earlier model, a request's
namewas compared by equality (absence included), which excluded every slot that declared no name. Under the one-rule model above, those slots match whenever their criteria are covered (the{fuel:petrol}cell in the last row of the matrix), and specificity decides as usual (case-table row 9). Outcomes differ only for a request carrying both anameand at least one tag. A request carrying only a name, only tags, or nothing resolves exactly as before.
The Binding discriminated union
Binding<Value> is the committed form of a binding: what the registry holds, what selection matches against, and what
BindingSnapshot and GraphNode are the public views of. What a binding declares is readonly; a handful of fields
the engine keeps for itself — inFlight, frame, rootContext, activationStamp, registrationOrder, and the cached
singleton instance — are writable, owned by the engine, and never set by callers.
BindingSlot — used for slot-aware last-wins and for resolution matching. BindingSlot carries tags — the
binding's whole criterion set, the reserved name criterion included ([] = the default slot) — name, the derived view
of the reserved criterion (undefined when the slot carries none), and keyMask, the derived bitmask of its tag keys.
Order inside tags does not affect equality.
Two BindingSlots are equal when their criterion sets are equal by the identity of each criterion (order does not
matter) — equivalent to Object.is on [key, value] thanks to interning; name, being derived, needs no separate
comparison.
Fields common to every binding (except where noted). Every committed binding carries: identifier, token, slot,
isMany (whether it is a many() collection member), and an optional predicate coming from .when().
whenNamed/whenTagged do not become part of the predicate — they go into the slot. When a binding declares both a
slot and a predicate, both must pass: the slot matches first at constant cost, the predicate is checked afterwards at
runtime.
Seven binding kinds, each adding its own fields on top of the common part above:
kind |
From | Own fields |
|---|---|---|
class |
.to(Class), .toSelf() |
target (constructor), scope, activationHook?, deactivationHook? |
dynamic |
.toDynamic() |
sync factory, scope, both hooks |
dynamic-async |
.toDynamicAsync() |
factory returning a Promise, scope, both hooks |
resolved |
.toResolved() |
sync factory, normalized deps, scope, both hooks |
resolved-async |
.toResolvedAsync() |
factory returning a Promise, deps, scope, both hooks |
constant |
.toConstantValue() |
value; scope is always "singleton", with no choice |
alias |
.toAlias() |
target token; scope is always "transient" (a placeholder), no lifecycle — it is only a pointer |
The hook fields are named activationHook/deactivationHook rather than after the fluent onActivation()/
onDeactivation() steps, because the chain that registers them is the binding object itself and a field cannot share a
name with a method. A deactivation hook only means anything when scope is "singleton" or "scoped"; that is
enforced by the builder's type, not at runtime. For constant, onActivation runs the first time the value is resolved
and its result is what gets cached.
Exact shape:
src/core/binding.ts—Bindingand its seven member interfaces.
Normative — normalization at commit time.
toSelf()→ aClassBindingwithtarget === token(the token must be aConstructor<Value>).- The deps array of
toResolved/toResolvedAsync: each element is aToken | Constructor | InjectionDescriptor. At commit time, a plainToken/Constructoris normalized into anInjectionDescriptorwith{ token, optional: false, multi: false }. ThedepsinResolvedBinding/ResolvedAsyncBindingis alwaysreadonly InjectionDescriptor[]— never a raw token.- A
BindingIdentifieris generated once per fluent chain, unique across the whole container hierarchy (not merely within one container), from a process-wide monotonic counter. A later scope or hook (.singleton(),.onActivation(), …) does not mint a new id — the id taken from.id()at any step of the chain stays valid until the chain ends.
The scope of an alias is its target's — at resolve time. An AliasBinding does carry a scope field, but it is
always "transient": a placeholder declared only so the engine reads scope as a plain field on every kind rather than
testing for the one that would lack it. An alias is followed to its terminal binding before anything is built, so the
resolved value's scope and lifecycle are the terminal binding's, never the alias's own "transient". If the chain ends
at another AliasBinding, keep following. If there is a cycle → CircularDependencyError.