Skip to content

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 })

ts
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" },
});
ParameterMeaning
injectRequired. 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 armExactly 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:

ArmShapeWhenScope in Needs?
valueSThe service is already at hand — a config object, a constant.No
sync(services) => SBuilt 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
classnew (services) => SBuilt by constructing a class, which takes the services record as its one argument.No
acquire + releaseacquire: (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:

  • value cannot fail and contributes never to the module's error channel.
  • make's error type is inferred from the Result it actually returns and joins the module's error channel — a failing make stops construction and surfaces through the entry point's Result as an Err. A make that throws instead of returning is a defect, not an Err.
  • class — the port's service type is the class's instance type; the constructor's one parameter is checked against the services record inject describes, so it destructures the same keys.
  • acquire/release come as a pair; neither exists without the other. acquire may fail exactly as make may. release runs during scope close, in reverse acquisition order; a failure in it is reported (see ScopedOptions) and swallowed, never rethrown.

onStart / onStop

Optional on every arm, supplied inline in the same options literal:

ts
Provider(Cache)({
  inject: { config: AppConfig },
  make: ({ config }) => connectCache(config),
  onStart: (cache) => cache.warm(),
  onStop: (cache) => cache.flush(),
});
HookWhen
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.

ts
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.

ts
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:

ChannelMeaning
PThe port it satisfies — the port's instance type.
EWhat construction may fail with: make/acquire's inferred error, never for the other arms.
NWhat 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.

Released under the MIT License.