ADR 0025 — Typed scoped client (consumed keys as args)
- Status: Accepted — extends ADR 0005
- Date: 2026-06-05
Context
createClient(contract, http, { pathPrefix, stripPrefixKeys }) already injects a
URL prefix and strips the consumed keys from the query/body at runtime. But
the returned type was TypedHttpClient<T>, whose method args come only from the
endpoint schemas — so a key the prefix consumes (tenantId) was not in the
argument type. api.list({ tenantId, … }) was a type error even though the
runtime needs it. A per-tenant consumer worked around it with a hand-written
~100-line ScopedClient<C> wrapper that intersected { tenantId: string } into
every method and cast the result.
Decision
Make createClient’s third arg type-aware: the keys named in stripPrefixKeys
become required string args on every method of the returned client.
ContractClientConfig<K extends string>—stripPrefixKeys?: readonly K[].createClient<T, const K>— theconsttype parameter infers the key tuple as literals (noas constneeded at the call site).- Return type
ScopedHttpClient<T, ScopedKeys<K>>whereScopedKeys<K> = [K] extends [never] ? unknown : { [P in K]: string }. Each method’s args becomeEndpointArgs<E> & Extra. TypedHttpClient<C>is redefined asScopedHttpClient<C, unknown>—EndpointArgs & unknown = EndpointArgs, so a plain client is unchanged and every existing usage keeps compiling.
const api = createClient(widgets, http, { stripPrefixKeys: ['tenantId'] })
api.list({ tenantId, status }) // tenantId typed + required, no wrapper
Runtime behaviour is unchanged — this is purely the type catching up to what
stripPrefixKeys already does. The consumer deletes its scoped-client wrapper.
Alternatives considered
- Keep the hand-written wrapper. Rejected — ~100 lines of boilerplate per
consumer for what the config already implies, and it needed an
ascast. - Type the
pathPrefixcallback arg with the consumed keys too. Dropped — it created variance friction with the runtime method factory for marginal gain; the method args (the real ask) are what carry the keys.
Consequences
- A resource-scoped client is fully typed from config alone — no wrapper, no
as. TypedHttpClientis now an alias ofScopedHttpClient<C, unknown>; structurally identical, so no breakage.- Scope-agnostic: the client only knows it needs the keys its own
pathPrefixconsumes — no domain model (ADR 0002 upheld).