Providers
Reference. A complete, structured description of
Provider. For the reasoning behind the arms and the channels, see Compile errors, not surprises; for the resourceful arm in practice, Manage a resource's lifetime. Full signatures: API reference.
A provider binds one port to one concrete construction. It is a description, not an instance: nothing runs until a module containing it is built.
Provider(port)({ inject, ...options })
Provider(OrderRepository)({
inject: { db: Database },
sync: ({ db }) => ({ findById: (id) => db.query(id) }),
});
Provider(AppConfig)({
inject: {}, // required, even with nothing to inject
value: { dbUrl: "postgres://localhost/orders" },
});| Parameter | Meaning |
|---|---|
inject | Required. A record of the ports this construction reads, under the names you choose for them. The arm's function (or constructor) receives one argument: a record with the same keys, holding the resolved services. A key the record does not declare is a compile error, and a value that is not a port is too. A provider with no dependencies writes inject: {} — required rather than optional so that a mistyped key (injec:) fails at the call, naming the property, instead of silently becoming a no-deps provider. |
| the arm | Exactly one construction arm, plus the optional hooks, in the same object. |
The dependency record is also what feeds the module's Needs channel: every port named here must be available where the module is built, or the graph is rejected — at compile time if the type is missing, as a wiring defect if a widened type slipped past.
Return type: Provider<InstanceType<P>, E, N> & { readonly port: P }. The provider carries the very port class it was declared for, typed — see The typed port below.
The construction family
Exactly one arm per provider. The arms are mutually exclusive by construction — an options literal supplying two arms' keys fails to compile, not merely warns:
| Arm | Shape | When | Scope in Needs? |
|---|---|---|---|
value | S | The service is already at hand — a config object, a constant. | No |
sync | (services) => S | Built synchronously from its dependencies, and cannot fail. | No |
make | (services) => Result<S, E> | AsyncResult<S, E> | Built fallibly, possibly asynchronously — a parsed config, a validated client. | No |
class | new (services) => S | Built by constructing a class, which takes the services record as its one argument. | No |
acquire + release | acquire: (services) => Result<S, E> | AsyncResult<S, E>, release: (s) => void | Promise<void> | A real resource — a connection, a file handle — that must be torn down. | Yes |
The axis the names encode is failability, not timing. sync is "cannot fail", make is "may fail" — and a make is free to be synchronous, returning a plain Result. The reverse is what does not exist: there is no asynchronous arm that cannot fail, because an AsyncResult<S, never> is already what a make returns when nothing in it fails. Read sync as infallible and make as fallible, and the pair stops looking like a question about await.
Notes per arm:
valuecannot fail and contributesneverto the module's error channel.make's error type is inferred from theResultit actually returns and joins the module's error channel — a failingmakestops construction and surfaces through the entry point'sResultas anErr. Amakethat throws instead of returning is a defect, not anErr.class— the port's service type is the class's instance type; the constructor's one parameter is checked against the services recordinjectdescribes, so it destructures the same keys.acquire/releasecome as a pair; neither exists without the other.acquiremay fail exactly asmakemay.releaseruns during scope close, in reverse acquisition order; a failure in it is reported (seeScopedOptions) and swallowed, never rethrown.
onStart / onStop
Optional on every arm, supplied inline in the same options literal:
Provider(Cache)({
inject: { config: AppConfig },
make: ({ config }) => connectCache(config),
onStart: (cache) => cache.warm(),
onStop: (cache) => cache.flush(),
});| Hook | When |
|---|---|
onStart: (service) => void | Promise<void> | After the whole graph has finished constructing, never while another provider is mid-construction; sequentially, in declaration order. A hook that throws or rejects is a defect — the entry point's use is skipped, later hooks do not run, and every finaliser already registered still does. |
onStop: (service) => void | Promise<void> | During teardown, LIFO alongside release finalisers. Declaring one puts Scope in Needs exactly as acquire does: it is teardown, and only Module.scoped / Module.forkScope ever open a scope to run it. Without that rule, a { value, onStop } provider would satisfy Module.build and the hook would silently never run. |
Hooks do not reopen arm exclusivity — { value, sync, onStart } is still a compile error.
The typed port
What Provider(port)(…) returns is Provider<P, E, N> & { readonly port: P } — the port class, typed, rides on the provider. It exists for the helpers that hand back a provider on a port the application never declared — Config.provider("Name")(schema), which mints one; a starter's api.OrpcRouter(contract)({ inject: { name: Dep }, unit?, sync }) / TemporalActivities(…) / AmqpHandlers(…), which target the starter's own fixed port — so the application holds one value and reads the port off it: provider.port is what another provider lists in its inject, what a module lists in exports, and what a hand-declared provider or a type test names.
const cacheProvider = Provider(Cache)({
inject: { config: AppConfig },
make: ({ config }) => connectCache(config),
});
const Warmer = Provider(Port("Warmer")<{ readonly go: () => void }>)({
inject: { cache: cacheProvider.port },
sync: ({ cache }) => ({ go: () => void cache.warm() }),
});Purely additive: the intersection is still a Provider<P, E, N> everywhere one is expected. Its declared type is a PortClassOf<Id, Service> when the port came from a helper, which is what lets a consumer export such a provider from a package with declaration: true.
Provider.member(port)({ inject, ...options })
The multi-binding form: contributes one member to a set port.
Provider.member(HealthCheck)({
inject: { db: Database },
sync: ({ db }) => ({ name: "database", run: db.ping }),
});Identical to Provider(...) in every respect — same arms, same hooks, same inject checking, same channels, same typed port — except the arm constructs one Member, not the port's whole readonly Member[]. Provider.member on an ordinary port does not compile: its member shape is never, so no arm can be satisfied. The reverse — Provider(...) on a set port — type-checks against the whole array and lands it as one member at runtime; contribute to a set port through Provider.member only.
The channels
Provider<P, E, N> carries three phantom channels, which the containing module aggregates:
| Channel | Meaning |
|---|---|
P | The port it satisfies — the port's instance type. |
E | What construction may fail with: make/acquire's inferred error, never for the other arms. |
N | What it needs: the union of inject's instance types, plus Scope when the arm is resourceful or onStop is set. |
The variance rule (shared with Module): capability channels are contravariant — you may forget what you have; obligation channels (E, N) are covariant — you may not forget what you owe. A type annotation can widen a provider's port, but no annotation can drop an error case or launder away Scope.
AnyProvider
The structural bound every provider satisfies — { port: AnyPort; deps: readonly AnyPort[] }, channel-free. It is what Module's provides is typed over, and what a package offering a shaped module (a starter's HttpModule(name)({ router, imports, provides, exports })) constrains its own provides with before handing the tuple to Module(name).
Construction semantics
During a build, providers are grouped into dependency levels; providers in the same level construct concurrently (all started before any is awaited), levels strictly in order. Each provider constructs once per build — every consumer of its port sees the same instance. Declaration order within a level makes error selection and hook order deterministic: the failure reported is the first Err in declaration order, except that a defect anywhere in the level outranks any Err.
overrideProvider(provider) — test-harness-facing
Marks a provider to replace the base provider for its port when the graph is planned — the base is never constructed. Two WiringDefects guard it: an override with no base in the tree ("nothing to override"), and two overrides for one port. It exists for @btravstack/testing's overridden, which is the intended caller; a production composition root that reaches for it is recomposing the lazy way — swapping an adapter is composing a different module, which stays the production answer.