ADR-0028: The TypeScript client is generated from the model, and stops at the query key
- Status: Working — all four layers are built and emitted into
example/tasks/web, whose typecheck is a gate inmise run ci - Confidence: Medium — twelve illegal requests are marked
@ts-expect-errorinweb/src/refusals.ts, so a widened type fails the build; Low still on the key shape and onqueryOptions, since no application has lived with either - Decided: 2026-07-28
- Last reviewed: 2026-07-28
Context
Section titled “Context”Vision describes the TypeScript client as fed by the OpenAPI
document, so the obvious move is to point openapi-typescript at it and write no
generator. Three things argue against it:
The document is lossy exactly where the value is. A filter parameter is
documented as array<string> with the operator vocabulary in prose, because that
is the most OpenAPI can say about ?status=eq.published. A generic generator
emits status?: string[], and status=bogus.x compiles.
Two hand-written clients confirm the shape of the loss. Both hand-roll a list
endpoint with URLSearchParams and bare string parameters, with a comment
explaining which values are legal — the most generatable function in either
codebase and the place where a typo compiles. Both also show what a generator
must not own: session storage, token refresh, 401 → redirect, JWT decoding.
The sharpest evidence is about cache keys. One client imposes a key factory
and an architecture test, and documents the two production bugs that motivated
it. The other writes 31 string-literal keys across 9 files; its change-feed
subscriber and its mutation handlers keep two invalidation lists that have
drifted, because ['draft', id] and ['drafts', id] are one character apart.
Nothing catches it. Both are the signature of a missing artefact, not a missing
rule.
Smaller, but telling: neither client surfaces filter.Error.Allowed. Both
flatten the error body to a string, so ADR-0011’s
property dies at the client boundary — a generated error type recovers an
existing guarantee rather than adding a feature.
Decision
Section titled “Decision”Generate the client from the same model metadata codegen already consumes — not
from the emitted OpenAPI document — and emit it into the consuming repo, the way
models_gen.go is emitted. Four layers, each usable without the one above:
- Row and body types. Hidden columns are absent from the row type entirely.
- Typed request parameters.
whereadmits only filterable columns with the operator set narrowed by column type;sortis a union of sortable columns and their-forms;selectandexpandnarrow the response type. - Transport functions, encoding those parameters into the URL grammar and taking an injected request function.
- A key factory and
queryOptionsfactories for list and item reads.
Explicitly not: the client shell, hooks, mutation helpers, optimistic updates, or a published npm package.
- Wire names keep the
jsontag spelling. Camel-casing needs a runtime mapping layer, and the point of the emitted client is types plus a URL encoder with nothing between the response and the caller. - The transport is injected — auth, refresh, retry and redirect-on-401 stay the application’s. This is ADR-0007’s seam argument in the other language.
queryOptions, not hooks. Hooks bake in React and get copied out and edited; aqueryOptionsobject is spread and overridden, which is composition rather than a fork.- An expanded collection keeps its envelope.
?expand=authorresolves toauthor: Author;?expand=taskstotasks: Collection<Task>. Typing the reverse asTask[]is the one shortcut to refuse — it reintroduces the silent truncation the envelope prevents, one layer further out. - An infinite-query factory is the second emitter, not a maybe.
next_cursoris exactly whatgetNextPageParamwants (ADR-0027), so no application has to know how paging works. Both observed clients hand-roll it fromhas_moreand an offset. - The key factory exists because the change feed will consume it. ADR-0012 delivers table-plus-row-key invalidations, which is only mechanical if something derives the key. If sqlb generates both the keys and the subscriber, the two lists that drifted in the observed client cannot disagree, because there is one list.
- Emitted into the repo, not published to npm. A client generated against the server it talks to cannot drift from it.
Consequences
Section titled “Consequences”Buys. A misspelled column, an operator its type does not accept, and a sort
on a column that did not opt in all fail at the TypeScript compile step —
ADR-0009’s property carried across the wire.
ADR-0011’s allowed list reaches the caller. And the change feed gets a consumer
whose invalidation is structural rather than conventional.
Costs. A second toolchain in a repository whose pitch is a Go module: CI needs
Node, and the emitted client takes @tanstack/react-query as a peer dependency.
The generated slice is a minority of a real client — in one observed service, 5 of 20 methods are schema CRUD, and the other’s API is command-shaped throughout. The generator must compose with hand-written code rather than own the namespace, which rules out a single client object.
Adoption is a migration, not a drop-in: both observed clients page by offset and
assume a total that is always there. And the key factory only fixes the half of
invalidation the schema knows about — a computed view is not a table, so its key
cannot be generated, and without a way to declare that dependency the generated
feed reproduces the observed bug for derived views.
What building it changed. The key factory sits in the dependency-free file,
because a change-feed subscriber needs keys and not a query client. Where is a
type alias, not an interface, for the implicit index signature. The item endpoint
offers no select, because rest registers it with
RejectUnknownQueryParameters. And the raw-parameters escape hatch was emitted
from the start, partly so that reaching for it is observable.
What would change our mind
Section titled “What would change our mind”- Callers reach for raw parameters more often than the typed
where— most likely when a filter is assembled from user input at runtime, which is the case this project exists for. Then the typed layer is an overlay, not the primary API. - Generated
queryOptionsget copied out and edited — the seam is wrong; revisit it rather than add options. - Response narrowing needs enough generic machinery that its type errors become unreadable — drop the narrowing and return the full row type.
- A second framework needs something other than
queryOptions— the TanStack layer becomes one of several opt-in emitters and layer 3 is the real product. - ADR-0012 lands with a payload that cannot be mapped onto keys mechanically — the key factory shrinks to what list and detail views need.
- A client team that does not run Go needs the SDK — the no-npm decision is the one to reopen, and the likeliest of these to happen.
Cost of change
Section titled “Cost of change”Cheap, and no longer free: one consumer exists in the repository and regenerates
from the schema, so tsc names what stops compiling.
The layers are cheap in both directions — stopping at layer 2 is a smaller generator rather than a wrong one, and deleting the TanStack layer breaks only the call sites that used it.
The key shape is expensive once anything consumes it. Keys become as const
tuples embedded in call sites, handlers and tests, and prefix invalidation is
structurally typed: a wrong prefix compiles and silently matches nothing — the
exact bug the factory exists to prevent. Snake_case is likewise expensive to
reverse: camel-casing later means a runtime mapper, which this design refuses, or
a breaking rename of every field.
Revisions
Section titled “Revisions”- 2026-07-28 — Written before implementation, prompted by reading two hand-written clients — one with a key factory and an architecture test, one with a live invalidation bug the factory would have made unrepresentable.
- 2026-07-28 — Reverse expansion landed, so expand-narrowing has two shapes. Recorded that the reverse must not be typed as a bare array.
- 2026-07-28 — Built; Exploring → Working. The layering, snake_case wire names,
injected transport and
queryOptionsunit all survived contact unchanged. Node is now pinned inmise.toml. - 2026-07-28 — Renumbered from 0026 and reconciled with keyset pagination, which made the infinite-query factory a concrete second emitter.
- 2026-07-30 — Condensed.