Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/div0.13.0· Specification

Foundation types

This section declares every foundation type used throughout the spec. The implementer must export all of them from @codefast/di. The table below is a map; each type is specified in its own subsection.

Type What it is Where you meet it
BindingScope The three instance lifetimes .singleton() / .transient() / .scoped()
BindingIdentifier An opaque id for one committed binding .id(), unbind(id)
Constructor A concrete new-able class .to(Class), .toSelf(), deps arrays
ActivationHandler / DeactivationHandler The two per-instance lifecycle callbacks .onActivation(), .onDeactivation()
ResolveOptions The hint a single resolve carries (name and/or tags) resolve(token, options), inject(token, options)
ResolutionContext What a dynamic factory receives .toDynamic((ctx) => …)
ConstraintContext Where the current resolve sits in the dependency graph .when((ctx) => …), advanced constraints
DependencySlot What one resolvable dependency declares @injectable([…]) deps, ParamMetadata
TokenValue Extracts Value from a token or constructor Type-level helper

BindingScope

type BindingScope = "singleton" | "transient" | "scoped";

BindingIdentifier

An opaque branded number — it cannot be constructed by hand from outside the library. It is handed out by .id() on a builder, by ResolutionFrame.bindingId, and by BindingSnapshot.id, and it is an identity to hand back to unbind(id), never a value to parse or display.

declare const BINDING_ID_BRAND: unique symbol;
type BindingIdentifier = number & { readonly [BINDING_ID_BRAND]: true };

Constructor

/**
 * Concrete constructor — can be called with `new`.
 * An abstract class does not satisfy this type; use Token<Value> for abstract classes.
 * `never[]` rest parameters keep a class with a typed constructor assignable under `strictFunctionTypes`.
 */
type Constructor<out Value = unknown> = new (...args: Array<never>) => Value;

Abstract classes. TypeScript does not allow new AbstractClass(), so an abstract class does not satisfy Constructor<Value>. To bind an abstract class as a token, use Token<Value> instead. container.bind(AbstractLogger) with AbstractLogger as an abstract class is a TypeScript error.

ActivationHandler and DeactivationHandler

An activation handler is the last step before an instance is handed out and cached: it can initialise or wrap the instance. A deactivation handler is the teardown step when an instance leaves its scope.

Normative — activation.

  • The handler receives the resolution context and the instance.
  • It runs after @postConstruct() and before the instance is cached into its scope. By then the instance has been fully new-ed, accessor initializers included.
  • It must return an instance — either the same one, or a Proxy wrapping it.
  • If it returns a Promise, the resolve must be resolveAsync().

Normative — deactivation.

  • The handler receives the instance and runs when the instance is evicted from its scope. Its return value is ignored.
  • It is called only for the scopes in the table below.
Scope / kind Deactivation runs? When
singleton Yes Container disposed, or the binding unbound
toConstantValue Yes — treated as a singleton, even if never resolved dispose() / unbind()
transient No — each instance is an orphan once handed to the caller —
scoped Yes — each child container deactivates the instance it cached That child disposed, or the binding unbound on it

Why a constant deactivates without a resolve. A singleton only exists after the first resolve, so if it is never resolved there is nothing to deactivate. A constant is the opposite — the value is supplied by the caller at bind time, so it exists from that moment. If the constant was resolved through onActivation, the hook receives the value after activation, not the original.

Exact shape: src/core/types.ts — ActivationHandler, DeactivationHandler.

ResolveOptions

Mental model. A single resolve may carry a hint made of criteria. A criterion is one [key, value] pair minted from a declared tag key. A name is also a criterion — one of the reserved key slotName — so name and tags feed one selection model, not two.

Fields. All three are optional:

Field Meaning Relationship to the others
name Selects a binding declared with whenNamed(name); typed as the names the token declares (Token API) Sugar for the criterion slotName.of(name)
tag Exactly one criterion Equivalent to a single element of tags
tags An array of criteria, read as a superset filter: it matches a binding whose every declared tag is in this array — not "the binding must carry all of these tags" Several criteria require tags

The full matching rule is in Slots and last-wins. InjectOptions accepts both tag and tags and folds tag into tags, so an InjectionDescriptor only ever carries one spelling.

Exact shape: src/core/types.ts — ResolveOptions.

Tag keys and criteria

Normative — a criterion is minted by TagKey.of(), and only by it.

  • A tag key is declared with tag<Value>(name).
  • key.of(value) returns an interned BindingTag: the same value always yields the same object. (Interning means keeping one canonical object per distinct value and returning it on every request.)
  • BindingTag is branded, so it cannot be constructed by hand.
  • key.peek(value) reads the intern cache without minting. The engine folds a request's name through it, so a name no binding ever declared is never retained.
const Region = tag<"eu" | "us">("app:region");
container.bind(Storage).whenTagged(Region.of("eu")).to(S3);
container.resolve(Storage, { tag: Region.of("eu") });

Tag value comparison

Normative — tag values compare by Object.is, on the fast path too. The intern cache must keep -0 separate from +0 to preserve this rule. NaN folds to one criterion, because Object.is(NaN, NaN) is true.

Because interning gives each value exactly one criterion, comparing criteria by identity answers exactly as Object.is on the value does — which is why a criterion may never be constructed any other way.

Passing tag and tags together

Normative. The request carries the union of both sources — equivalent to tags: [tag, ...tags], and InjectOptions folds it into exactly that shape.

ResolutionContext

ctx is what a dynamic factory (toDynamic / toDynamicAsync) receives. It is not a full container — it opens up exactly the ability to resolve within the current context.

  • Six methods, each taking a token plus the same optional hint: resolve, resolveAsync, resolveOptional, resolveOptionalAsync, resolveAll, resolveAllAsync.
  • resolveAll throws AsyncResolutionError if any matching binding is async, and returns [] when nothing matches. Both collection reads return a ReadonlyArray that belongs to the caller: every read hands out a fresh array, the memoized root-level read included, so writing into one can never change what a later read returns.
  • graph holds the ConstraintContext — the dependency-graph context used inside a when() predicate. An ordinary resolve never needs it. Read from a factory, it describes the factory's own level: its resolutionStack ends with the frame of the binding the factory is building, so parent is that binding — what a dependency the factory resolves sees as its parent.

Exact shape: src/core/types.ts — ResolutionContext.

ConstraintContext

Mental model. ConstraintContext answers "where am I in the current resolve?" — which token is being built, which binding asked for it, and every binding above that. A when() predicate reads it to decide whether a candidate applies.

Five fields:

Field Contents
resolutionPath The token names along the current resolve path, readonly — a chain of labels
resolutionStack The full ResolutionFrames along the construction chain — enough metadata to detect a captive dependency
parent The frame directly above, undefined at the root
ancestors Every frame above parent, root first
currentResolveOptions The hint passed into the current resolve, undefined if there is none

A ResolutionFrame holds: tokenName (for display in error messages), scope, bindingId, kind, and the slot of the binding matched for that frame. A slot is the binding's criterion set: tags (every criterion, the reserved name criterion included) plus name, the derived view of the reserved criterion (undefined if the binding declares no whenNamed()), and keyMask, a bitmask of the tag keys present that lets a constraint rule out a frame without walking its tags — see Slots and last-wins.

Normative. A frame's slot reflects the constraint registered at bind time, not the hint passed at resolve time. The advanced constraints in Advanced Constraints read exactly this field.

BindingKind is one of seven values: class, dynamic, dynamic-async, resolved, resolved-async, constant, alias.

Exact shape: src/core/types.ts — ConstraintContext, ResolutionFrame, BindingKind.

resolutionStack ordering and its views

Normative.

  • resolutionStack is a readonly view of the entire resolution path above the current token — it does not include the token being resolved. It is valid while the predicate runs: the engine reuses the path it views, so a context kept past its call is not guaranteed to stay consistent with itself.
  • Order: from the root (index 0) to the direct parent (last index).
  • parent and ancestors are computed views over the same data, and the implementer must keep them consistent:
ctx.parent === ctx.resolutionStack.at(-1); // nearest frame, undefined at the root
ctx.ancestors === ctx.resolutionStack.slice(0, -1); // everything but the nearest frame

Example — the resolve chain App → Database → Logger (root App, direct parent Database, currently resolving Logger):

resolutionStack = [App_frame, Database_frame]  // index 0 = root
parent          = Database_frame               // resolutionStack.at(-1)
ancestors       = [App_frame]                  // resolutionStack.slice(0, -1)

When resolving App at the root (nothing injects App):

resolutionStack = []
parent          = undefined
ancestors       = []

resolutionPath vs resolutionStack. resolutionPath is an array of tokenName strings, enough to display in an error message ("App → Database → Logger"). resolutionStack holds full ResolutionFrames (scope, bindingId, slot) — used by advanced constraints and by validate. Both describe the same path, and a rule stated over one holds over the other.

DependencySlot

The shape one resolvable dependency declares, whichever source it came from: a constructor parameter read through the MetadataReader, or an element of a toResolved deps array.

interface DependencySlot {
  readonly token: Token<unknown> | Constructor;
  readonly optional: boolean;
  readonly multi: boolean;
  readonly name?: string | undefined;
  readonly tags?: ReadonlyArray<BindingTag> | undefined;
}

Normative — both dependency sources are this shape. InjectionDescriptor (what inject, optional and injectAll return) and ParamMetadata (what a MetadataReader reports per constructor parameter) each extend it, adding only what is theirs: a value type parameter and an index respectively. Every rule the specification states about a dependency's optional, multi, name and tags therefore holds for both.

A slot's criteria are a request waiting to be made: injectionSlotToResolveOptions(slot) turns the name and tags of one into the ResolveOptions that asks for them, omitting a key rather than setting it to undefined, and answering undefined when the slot states no criteria. The binding side has its own converter (Resolving what a snapshot points at).

Exact shape: src/injection/dependency-slot.ts — DependencySlot.

TokenValue

A helper type that extracts Value from Token<Value> or Constructor<Value>:

type TokenValue<Type> = Type extends Token<infer Value> ? Value : Type extends Constructor<infer Value> ? Value : never;