# @fnioc/di The runtime engine for `ioc`. Resolves dependency graphs, manages scope lifetimes, preserves captive-dependency correctness, and drives native disposal. No decorators. No `reflect-metadata`. No runtime type introspection. Feed it string tokens and dep arrays (generated by `@fnioc/transformer` or written by hand via `@fnioc/core`'s authoring surfaces) and it handles the rest. --- ## Design philosophy — scopes are uniform tags **Scopes are uniform tags — there is no root.** `"singleton"` is literally just a tag you happen to open once at the top. You can run the container without ever opening a scope at all; with no matching frame open, resolution is transient. `build()` returns a **frameless** provider — nothing is pre-opened, and there is no provider-level instance cache. A registration's lifetime tag caches its instance in the nearest enclosing **open** frame carrying that tag; if no such frame is open, the instance resolves transiently (fresh, no cache, no error) — exactly like an untagged registration. Open a frame with `createScope(name)` when you want a tag to cache. Captive-dependency safety is preserved structurally: a service's deps resolve relative to the frame that owns it, so a longer-lived service can never cache-capture a shorter-lived one — it gets a fresh transient instead. --- ## `ServiceManifest` The entry point. `Scopes` is the union of declarable scope-name tags (default `"singleton"`). The tags `.as()` and `createScope()` accept are exactly its members. Transient (no cache, fresh instance on every resolve) is the default — there is no `"transient"` scope; the absence of `.as()`, or the absence of an open frame for a tag, is what makes a registration transient. ```ts import { ServiceManifest } from "@fnioc/di"; const services = new ServiceManifest<"singleton" | "request">(); ``` Registration is append-only: each token holds a **list** of registrations in registration order, and resolution picks the most-recent (last) one. A later `add` for the same token therefore overrides an earlier one without deleting it. ### `.add(Concrete).as<"scope">()` Register a concrete implementation against an interface token. The transformer rewrites `add(Foo)` to `add("pkg:IFoo", Foo)` at build time. Hand-fed consumers pass the token string directly. ```ts // With transformer (author form): services.add(ConsoleLogger).as<"singleton">(); services.add(SqlUserRepo).as<"request">(); services.add(UuidRequestId); // no .as() → transient // Without transformer (lowered form, or plugin-less): services.add("pkg:ILogger", ConsoleLogger).as("singleton"); services.add("pkg:IUserRepo", SqlUserRepo).as("request"); services.add("pkg:IRequestId", UuidRequestId); ``` The type constraint on `Concrete` is `new (...args: any[]) => Interface` — plain `new`, not `abstract new`. Abstract classes are correctly rejected because the container instantiates the concrete. `.as()` checks at compile time that `S` is a declared scope name. Passing an undeclared string is a type error. ### `add(Concrete, sig)` — registration-time signature override For third-party classes (ctor not editable) or generic instantiations the transformer cannot infer, supply a positional override array alongside the class: ```ts add(RedisCache, ["pkg:IRedisClient", undefined, "pkg:ILogger"]); ``` `sig` is `readonly (DepSlot | undefined)[]` — a positional sparse override over the transformer-generated signature. A `DepSlot` at a position overrides the generated token there; `undefined` keeps the generated token. Use explicit `undefined` rather than sparse elision (no-sparse-arrays). Pure token users (no transformer) supply a complete signature via the registration's own third argument (`add(token, C, signatures)`) instead. ### `add(token, Ctor, signatures?)` — registration-carried dep signatures Not to be confused with the sparse `sig` override above — that's a type-driven, compile-time-only feature consumed entirely by the transformer. This is the **runtime** form of `add`: an optional third argument, `signatures`, a complete (non-sparse) multi-signature array carried directly on the registration record. ```ts add(token: Token, ctor: Ctor, signatures?: readonly (readonly DepSlot[])[]): AddBuilder addFactory(token: Token, factory: Factory, signatures?: readonly (readonly DepSlot[])[]): AddBuilder ``` There is no global, ctor-keyed metadata store — this array **is** the sole signature channel, for both classes (`add`) and factories (`addFactory`). Keying it on the registration rather than the constructor function is what lets one JS class back **any number of independent registrations** with different signatures — the mechanism open-generic registrations depend on, where the same erased class serves every closing of a template (see [Open generics](#open-generics) below). `@fnioc/transformer` emits this array inline for every registration it can statically extract a signature from — `add(Foo)` lowers to `add("pkg:IFoo", Foo, [[...]])`, with no separate prelude call and nothing hoisted. Hand-write it directly for the plugin-less path. Omitting the third argument leaves the registration signature-less: fine for a zero-arg constructor, but a constructor with parameters throws `MissingMetadataError` at resolve time. ### `addFactory(token, factory, signatures?)` and `addValue(token, value)` Two more registration surfaces alongside `add` — recommended for test doubles, third-party instances, and plugin-less consumers. ```ts // Signature-less factory: receives the live Resolver, resolves its own deps. services.addFactory("pkg:IDb", (sp) => new PostgresDb(sp.resolve("pkg:IConfig"))) .as("singleton"); // Factory with a signature: each param is injected by its slot, like `add`. services.addFactory("pkg:IDb", (config) => new PostgresDb(config), [["pkg:IConfig"]]) .as("singleton"); // Value: a pre-constructed instance (re-used as-is, no lifetime) services.addValue("pkg:ICache", new NullCache()); ``` `addFactory` returns the `.as(scope?)` continuation, exactly like `add` — `.as("singleton")` caches the result in the nearest enclosing open `"singleton"` frame; no `.as()` call runs the factory fresh on every resolve (transient). A signature-less factory (no third argument) is called with the live `Resolver` as its sole argument and resolves its own deps by hand — the plugin-less escape hatch. `addValue` takes no lifetime — the value is always the same reference, and it returns `void`, not a builder. To override a registration for a specific context (e.g. a test double), register a later spec for the same token on the `ServiceManifest` before calling `build()`. The registration map is append-only and last-registration-wins. The map seals at `build()` — no post-build mutation is possible. --- ## Scope model Scopes are uniform tags forming a parent-linked chain. There is no root: `build()` returns a frameless provider, and frames are opened only by an explicit `createScope` — never auto-created. `"singleton"` is just the tag you open once at the top. ```ts const provider = services.build(); // frameless — nothing pre-opened const app = provider.createScope("singleton"); // open the app-lifetime frame const req = app.createScope("request"); // per HTTP request ``` **Resolution walks the enclosing chain for instance ownership:** the lifetime tag names which enclosing open frame caches the instance. Walk up to the nearest enclosing frame whose name matches the tag and cache there. (Registration lookup is flat — the sealed map is shared across the whole tree.) **Lifetime rules:** | Registration | Behavior | |---|---| | No `.as()` (transient) | Fresh instance on every resolve. Never cached. | | `.as("singleton")` | Owned and cached by the nearest enclosing **open** `"singleton"` frame. | | `.as("request")` | Owned and cached by the nearest enclosing **open** `"request"` frame. | | Tag with no enclosing open frame | **Transient.** Fresh instance, no cache, no error — an absent frame is just transient. | ### Captive-dependency protection The critical correctness rule: deps are resolved **relative to the frame that will own the instance**, not the frame that triggered the resolve. This is what keeps a longer-lived service from cache-capturing a shorter-lived one. ```ts const services = new ServiceManifest<"singleton" | "request">(); services.add(RedisCache).as<"singleton">(); services.add(HttpUserContext).as<"request">(); services.add(UserService).as<"singleton">(); // UserService constructor: (cache: ICache, ctx: IUserContext) const app = services.build().createScope("singleton"); const req = app.createScope("request"); req.resolve("pkg:IUserService"); // UserService is singleton-owned. Its deps resolve from the singleton frame's // chain, which has no ENCLOSING "request" frame (request is a descendant). So // IUserContext resolves to a FRESH transient — never the request's cached // instance. The singleton cannot capture one request's IUserContext and hold it // across every subsequent request. ``` This preserves `Microsoft.Extensions.DependencyInjection`'s captive-dependency safety, via the uniform-tag transient fallback rather than a throw: the construct-relative-to-owner rule guarantees a fresh transient is the worst that happens, never a captured cached instance. --- ## Open generics A registration whose service token contains a **hole** (`$1`, `$2`, …) is an *open* (template) registration — it doesn't cache one instance, it matches **any** closing of its base + arity at resolve time. ```ts // Open registration: matches any closing of IRepository, one hole per arg services.add>>(SqlRepository<$<1>>).as<"singleton">(); // Each closing resolves and caches independently const userRepo = scope.resolve>(); // "pkg:IRepository" const orderRepo = scope.resolve>(); // "pkg:IRepository" // distinct singleton instances — the closed token is the cache key ``` ### Registration rules - **All-holes only.** Every type-arg position in an open service token must be a hole — `IFoo<$1,$1>` is allowed (repeats mean "match only equal args"); mixing concrete args and holes (`IFoo<$1,User>`) is a registration error. - **Class registrations only.** `addValue`/`addFactory` reject an open token — there is no single value or factory that could serve every closing. - **`.as(tag)` applies per closing**, not to the template as a whole — `IRepository` and `IRepository` are distinct singletons, each cached in the nearest enclosing frame carrying `tag`, exactly like two unrelated `.as("singleton")` registrations. - **Last-registered wins** among multiple open registrations matching the same base + arity (and satisfying any repeated-hole equality constraint) — same semantics as the exact-match list. ### Resolve-time fallback and memoization Resolving a token the exact-match map has no entry for falls through, in order: 1. **Memo** — a closed token already synthesized on a previous resolve returns the *same* `Registration` object (identity-stable — this is what makes per-closing caching correct across repeat resolves). 2. **Parse.** A non-generic token that misses here is simply unregistered. 3. **Open-table match** — search open registrations for the same base + arity (respecting repeated-hole equality), most-recently-registered first. 4. **Substitute** — the open registration's carried dep signatures are substituted with the closing's concrete type args (`TypeArgRef` slots become `LiteralRef`s carrying the substituted token). 5. **Synthesize** a `ClassRegistration` for the closed token — inherits the ctor and scope tag, carries the substituted signatures — and memoize it. **Exact beats open.** An exact registration for a closed token — one you registered directly, e.g. `services.add>(SpecialUserRepo)` alongside the open `IRepository<$<1>>` registration — is checked *before* the memo and the open-table fallback, so it always wins. **Resolving a token that still contains a hole throws.** `scope.resolve("pkg:IRepository<$1>")` is not a valid resolve target — only closed tokens resolve. See `OpenTokenResolutionError` below. ### Errors | Error | Thrown when | |---|---| | `OpenTokenResolutionError(token)` | `resolve()` (directly or transitively) is asked for a token that still contains an unbound hole. | | `OpenTokenRegistrationError(token, method)` | `add()` is given a service token that mixes concrete args and holes, or `addValue()`/`addFactory()` is given any open token. `method` names the call that rejected it (`"add"`, `"addValue"`, `"addFactory"`). | ### Manual / plugin-less path No transformer required — template tokens are just strings with `$N` holes, and the grammar helpers are plain functions re-exported from `@fnioc/core`: ```ts import { closeToken, typeArg } from "@fnioc/di"; // Template registration — carried signatures include a TypeArgRef via typeArg(1) services.add( "app:IRepository<$1>", SqlRepository, [["app:IDbConnection", typeArg(1)]], ).as("singleton"); // Resolve closings by hand-closing the token const userToken = closeToken("app:IRepository", "app:User"); // "app:IRepository" scope.resolve(userToken); ``` Because the signature array lives on the **registration**, not on the ctor object, the same class can back any number of independent templates (or an open template alongside a closed override) without collision — each `add(...)` call carries its own array: ```ts // SqlRepository backs an open template... services.add("app:IRepository<$1>", SqlRepository, [["app:IDbConnection", typeArg(1)]]); // ...and a second, unrelated open template for a different service base, // with its own independent signature array. No collision: each registration // owns its own signatures. services.add("app:IAuditLog<$1>", SqlRepository, [["app:IAuditConnection", typeArg(1)]]); ``` --- ## Greedy overload selection When a registration's carried signature array holds multiple entries (one per constructor overload), the engine selects by scanning **longest → shortest** and picking the first signature where every resolvable parameter token is satisfiable (registered in the container). Equal-arity ties break by array order. ```ts // Two overloads: prefer the one with ILogger if available class MyService { constructor(logOrDb: ILogger | IDb, db?: IDb) { ... } } services.add("pkg:myService", MyService, [ ["pkg:IDb"], ["pkg:ILogger", "pkg:IDb"], ]); ``` If ILogger is registered, the two-parameter signature wins. If not, the one-parameter signature is used. --- ## Cycle detection The engine maintains a resolution stack per `resolve()` call. If a token appears on the stack when it is about to be pushed again, it throws with the full path: ``` Circular dependency detected: pkg:IUserRepo → pkg:IDb → pkg:IConnectionPool → pkg:IDb ``` --- ## Disposal Closing a scope disposes the instances it owns in **reverse construction order**. Only instances implementing the native TC39 disposal contract are disposed. ```ts // Sync disposal scope.dispose(): void // Async disposal scope.disposeAsync(): Promise // Native using / await using (TypeScript 5.2+, requires "ESNext.Disposable" in lib) { await using req = root.createScope("request"); // req.disposeAsync() called automatically on block exit } ``` `Symbol.dispose` and `Symbol.asyncDispose` only — no custom `dispose()` interface. Sync `dispose()` throws if the scope owns a `Promise`-valued disposable, directing you to `disposeAsync()`. Async teardown is never silently skipped. Instances owned by ancestor scopes are disposed when those scopes close, not when child scopes close. --- ## Async resolution `resolve()` never lies about what it returns — it's synchronous, full stop. Two entry points, two honesty guarantees: - **`resolve(token)`** — synchronous. If satisfying `token` would require waiting on an in-flight async construction (a concurrent `resolveAsync` mid-build for the same cached instance), it throws `AsyncResolutionRequiredError` rather than block or hand back an unsettled value. - **`resolveAsync(token)`** — always returns a `Promise`. It is the **only** path that can satisfy a lookup miss via the token's honest `Promise` counterpart. ### Honest `Promise` token-split An async dependency is tokenized at its **true** `Promise` type — never unwrapped to `X`. Register the async factory under the `Promise` token directly: ```ts services.addFactory("Promise", async (sp) => { const pool = sp.resolve("pkg:IConnectionPool"); return new PostgresDb(await pool.connect()); }).as("singleton"); ``` - `resolve>("Promise")` returns the **raw promise** — the honest, synchronous view of an async registration. - `resolveAsync("pkg:IDb")` finds no direct `"pkg:IDb"` registration, falls back to `"Promise"`, and awaits it — delivering the settled `IDb`. A constructor parameter typed as the bare interface (`IDb`, not `Promise`) hits exactly this path: it's satisfiable only in async mode, and the value the constructor actually receives is the **awaited** result, never the promise itself. ```ts class UserRepo { constructor(private db: IDb) {} findUser(id: string) { return this.db.query(`SELECT * FROM users WHERE id = $1`, [id]); } } const repo = await root.resolveAsync("pkg:UserRepo"); ``` The container caches whatever a factory returns, verbatim — for an async factory, that's the `Promise` itself. Every resolve of the same cached token gets the same `Promise`; the factory runs exactly once. Single-flight applies across overlapping `resolveAsync` calls: the in-flight promise lands in the cache before it settles, so concurrent resolves for the same singleton share one construction instead of racing to build it twice. `@fnioc/transformer` derives tokens just as honestly: a constructor parameter or factory return typed `Promise` derives the token `Promise`, at any depth, never unwrapped. See [`@fnioc/transformer`](../transformer/README.md#async-dependencies) for the token-derivation side. --- ## Factory injection A constructor parameter whose type annotation is an inline function type returning a registered interface is injected as a **factory** — a callable that builds the target on demand — rather than a resolved instance. ```ts // IDb is a registered class. This parameter receives a callable: constructor(makeDb: () => IDb) { ... } // Partial factory — the caller fills caller-supplied params: constructor(makeRepo: (tableName: string) => IUserRepo) { ... } ``` ### Named function-interface opt-out A **named** callable interface is NOT treated as a factory — it resolves as a normal service keyed on that interface's own token: ```ts interface IDbFactory { (): IDb } // Resolves as the "pkg:IDbFactory" token, not a factory for IDb constructor(dbFactory: IDbFactory) { ... } ``` Name the interface to opt out of factory interpretation whenever your function-typed service should itself be a registered dep. ### `resolveFactory(type, params?)` Resolve a factory callable for the token rather than an instance: ```ts // Without params → strict zero-arg () => T; every slot must resolve from the container const makeDb = scope.resolveFactory("pkg:IDb"); const db = makeDb(); // all deps resolved from container // With params → factory (...params) => T; named tokens filled by caller, rest from container const makeRepo = scope.resolveFactory("pkg:IUserRepo", ["app:tableName"]); const repo = makeRepo("users"); // tableName filled by caller; ILogger, IDb from container ``` `params` is the complete authored-order list of caller-supplied token strings, matched by token (first-occurrence, left-to-right). Passing `params` pins the factory's shape — it no longer drifts as registration state changes. ### Partial / positional factories The injected callable exposes **only the target constructor's caller-supplied parameters**, in their relative order. Registered deps are resolved by the container at call time. ```ts // IUserRepo concrete: constructor(log: ILogger, tableName: string, db: IDb) // ILogger and IDb are registered; tableName is not registered (caller-supplied). // Injected factory type: (tableName: string) => IUserRepo class RequestHandler { constructor(private makeRepo: (tableName: string) => IUserRepo) {} handle() { const repo = this.makeRepo("users"); // At call time: new UserRepo(resolve(ILogger), "users", resolve(IDb)) } } ``` There are no Ramda-style placeholders. The factory's call arity is exactly the count of caller-supplied parameters; the caller never sees the full constructor shape. ### Lifetime semantics The injected factory is a closure captured at injection time, referencing the owning scope. How the target's instance is managed depends on whether the factory is parameterized: | Factory kind | Lifetime behavior | |---|---| | **Zero-arg** (`() => IFoo`, no caller-supplied params) | Routes through normal `resolve` — respects the target's registered lifetime. A singleton target returns the same instance on every call; a transient target yields a fresh one. | | **Parameterized** (caller args fill caller-supplied params) | Builds a **fresh instance on every call**, bypassing the instance cache. Caller args differ per invocation, so caching would be wrong — two calls with different arguments must not collapse to one cached instance. | The captive-dependency rule holds at call time: the target's own deps are resolved relative to the frame that owns the factory-holding instance. A factory captured by a singleton that builds a request-scoped target whose `"request"` frame is not enclosing produces a fresh transient when invoked — never a cache-captured request instance. ### `FactoryTargetError` Thrown when the container tries to build the factory callable and cannot. Two reasons: | Reason | Meaning | |---|---| | `"unregistered"` | The factory's target token has no registration. A factory parameter needs the target registered with `services.add(...)`. | | `"not-a-class"` | The target is registered via `addValue` or `addFactory`, not a class. A factory builds its target with `new`; only class registrations qualify. Resolve it directly or change the registration. | Note: `FactoryTargetError` is thrown when the factory callable is constructed (at owning-class resolution time), not when the callable is invoked. --- ## Union slots A `Union` dep slot tries each member in declaration order and resolves to the first registered one. A member that is statically resolvable but throws at build time (a cycle, an unresolvable nested dep) falls through to the next. Throw if none resolves. ```ts import { union } from "@fnioc/di"; services.add("pkg:IHandler", Handler, [[ union("pkg:IRedis", "pkg:IMemoryCache"), "pkg:ILogger", ]]); ``` Token users construct `Union` slots with `union(...)`. Transformer users write an inline `A | B` annotation and the transformer lowers it automatically. See [`@fnioc/transformer`](../transformer/README.md) for the named-vs-inline distinction. --- ## API reference ### `ServiceManifest` Zero-argument constructor — there is no `rootName`; scopes are just tags. | Member | Signature | Description | |---|---|---| | `add(Concrete)` | `(ctor: new (...) => I) => AddBuilder` | Register a concrete class against interface `I`. | | `add(Concrete, sig)` | `(ctor, sig: readonly (DepSlot \| undefined)[]) => AddBuilder` | Register with a positional signature override. | | `.as()` | `(scope: S) => void` | Set the lifetime scope tag. No call → transient. | | `add(token, ctor, signatures?)` | `(token: string, ctor, signatures?: readonly (readonly DepSlot[])[]) => AddBuilder` | Class registration (lowered form). An open (holey) token routes to the open-registration table; `signatures` is the sole signature channel (open generics, §Open generics). | | `addFactory(token, factory, signatures?)` | `(token: string, factory: Factory, signatures?: readonly (readonly DepSlot[])[]) => AddBuilder` | Factory registration. Without `signatures`, the factory receives the live `Resolver` as its sole argument; with `signatures`, each call param is injected by its slot, like `add`. | | `addValue(token, value)` | `(token: string, value: unknown) => void` | Value registration. A pre-built instance, re-used as-is. | | `build()` | `() => ServiceProvider` | Seal the registration map and return a **frameless** `ServiceProvider` (no scope pre-opened). No post-build mutation is possible. | ### `ServiceProvider` Implements `Resolver` + `ScopeFactory` + `Disposable` / `AsyncDisposable`. | Member | Signature | Description | |---|---|---| | `resolve(token)` | `(token: string) => T` | Resolve an instance synchronously. A tagged registration with no enclosing open frame resolves transiently; throws on unregistered token, a cycle, or a cached in-flight async construction (`AsyncResolutionRequiredError`). | | `resolveAsync(token)` | `(token: string) => Promise` | Resolve asynchronously. The only path that can satisfy a lookup miss via its honest `Promise` registration (§Async resolution). | | `resolveFactory(type, params?)` | `(type: string, params?: readonly string[]) => (...args) => T` | Resolve a factory callable. Without `params`, strict zero-arg `() => T`; with `params`, `(...params) => T` matched by token. | | `createScope(name)` | `(name: Scopes) => ServiceProvider` | Create a nested child scope. | | `dispose()` | `() => void` | Sync close. Throws if any owned instance has async-only disposal. | | `disposeAsync()` | `() => Promise` | Async close. | | `[Symbol.dispose]()` | — | Native `using` support. | | `[Symbol.asyncDispose]()` | — | Native `await using` support. | --- ## TypeScript configuration Disposal support requires `"ESNext.Disposable"` in your `lib` array. `"ES2022"` alone does not include the disposal symbols. ```jsonc { "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "ESNext.Disposable"] } } ```