TypeScript client
The REST layer’s filter grammar is precise on the server: a column that never
declared .Filterable() cannot be filtered on, and the rejection says what
would have been accepted. None of that reaches a browser by itself. A
hand-written client spells ?status=eq.published into URLSearchParams and
takes half a dozen bare string parameters, and a typo compiles.
codegen emits a TypeScript client that closes that gap, from the same schema
declaration the Go models come from.
Turning it on
Section titled “Turning it on”Set TSDir on the generator you already have:
codegen.Must(codegen.Generate(codegen.Options{ Registry: schema.DefaultRegistry(), Dir: "blog", Package: "blog",
// Relative to Dir. Three files land here; nothing is emitted without it. TSDir: "web/src/api",}))Three files, because the layers are usable separately:
runtime.gen.ts |
Page, Collection, Problem, Transport and the filter encoder — the part that depends on no schema. Imports nothing. |
client.gen.ts |
Row types, request bodies, the typed parameter vocabulary, one function per exposed operation, and the cache keys. Imports the runtime, and re-exports it. |
queries.gen.ts |
TanStack Query queryOptions and infiniteQueryOptions. Takes @tanstack/react-query as a peer dependency. Set TSQueriesFile: "-" to skip it. |
The runtime is a file of its own so that an application with more than one
generated module has one Page and wires one Transport rather than N
(#110). Point two modules at one
TSDir and they share it: nothing in it is schema-specific, so the second
writer produces the same bytes and check stays meaningful for both.
A project with one module need not notice. client.gen.ts re-exports
everything the runtime holds, so import type { Page } from './client.gen'
keeps compiling exactly as it did.
The client is emitted into the repository that consumes it, the way
models_gen.go is. There is no npm package to install and therefore no way for
the client to be a version behind the server it talks to.
codegen.Check covers all three, so the usual staleness gate catches a schema
change that was never regenerated.
What the types know
Section titled “What the types know”Everything a column declared, and nothing it did not:
const page = await listPosts(request, { where: { status: { in: ['draft', 'published'] }, // the enum's values title: { contains: search }, // pattern operators: it is text published_at: { notnull: true }, // a null test: it is nullable view_count: { gte: 100 }, labels: { has: 'urgent' }, // containment: it is an array }, sort: ['-published_at', 'title'], select: ['title', 'status'], expand: ['author'], per_page: 50,});whereadmits filterable columns only, and the operator set is narrowed by column type.containson a number does not compile; neither doesisnullon a non-nullable column, nor a value outside an enum.- An array column takes
ArrayCond:hasfor one element,hasanyandhasallfor a list, their negationsnhas,nhasanyandnhasall, and a bare array for whole-array equality. It has nocontains— that is the text substring operator, and one name meaning two things depending on the column is precisely the ambiguity this client exists to remove. It has no ordering operators either. The negations areNOT (…)rather than complements, so a null column matches neitherhasnornhas. sortis a union of the sortable columns and their-forms. An array column is never in it.selectnarrows the response type.page.items[0].titleis available after the call above;page.items[0].bodyis not. The primary key is always present, because the server adds it back to any projection that dropped it.expandwidens it. A forward relation resolves to the row type; a reverse one toCollection<T>—{items, has_more}— so a capped expansion cannot be mistaken for a complete list.- Hidden columns have no spelling anywhere. Not in the row type, not in
select, not inwhere.
This is the typed column facade carried across the wire, and it is
why the client is generated from the schema rather than from the OpenAPI
document: the document can only say array<string> about a filter parameter,
with the operators in prose.
The transport is yours
Section titled “The transport is yours”The generated functions take a request function as their first argument rather than constructing one. Base URL, auth header, refresh, retry and what a 401 does are not derivable from a schema, and are the parts of a real client that matter most:
import { type ApiRequest, type Transport } from './api/client.gen';
export const request: Transport = async <T>({ method, path, query, body, signal }: ApiRequest): Promise<T> => { const res = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, { method, headers: { ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(token() === null ? {} : { authorization: `Bearer ${token()}` }), }, body: body === undefined ? undefined : JSON.stringify(body), signal, }); if (!res.ok) throw await res.json(); return res.status === 204 ? (undefined as T) : ((await res.json()) as T);};This is the same seam rest takes by mounting onto a huma.API you built. It
also means the generated functions compose with hand-written ones: a login
endpoint is not a table, and no schema generator will produce it.
Rejections keep their allow-list
Section titled “Rejections keep their allow-list”A 400 from the filter grammar carries what would have been accepted. The client types that body rather than flattening it to a message, so a UI can offer the alternatives:
import { allowedFor, isProblem } from './api/client.gen';
if (isProblem(body)) { const sortable = allowedFor(body, 'query.sort'); // ["title", "view_count", ...]}Paging
Section titled “Paging”next_cursor is on every list response with a page after it, which is exactly
the shape infiniteQueryOptions wants:
const feed = postQueries(request).infinite({ sort: '-published_at', per_page: 50 });page and cursor are absent from that factory’s parameters: they are two
answers to where a page starts, and the factory owns the answer. Paging by hand
is the same loop with cursor threaded through next_cursor, and it costs the
same at any depth.
Cache keys
Section titled “Cache keys”One factory per resource, plus a table-keyed index:
taskKeys.lists(); // ['tasks', 'list']taskKeys.detail(id); // ['tasks', 'detail', id, {}]keysByTable['tasks'].lists(); // the same list, reached from an event payloadThe index exists so that a change event — a table plus a row key — maps onto
cache keys mechanically. Two hand-maintained invalidation lists drift; the bug
that motivated this was ['draft', id] against ['drafts', id] in a client
where mutations and an event stream each kept their own list.
What is not generated
Section titled “What is not generated”Hooks, mutation helpers, optimistic updates, a client object, an npm package.
Hooks bake in a framework and get copied out and edited; a queryOptions object
is spread and overridden instead:
{ ...postQueries(request).list({ sort: 'title' }), staleTime: 30_000 }example/tasks/web is a worked one, including
the refusals — the requests that must
not compile, asserted with @ts-expect-error so that a generator which
widened a type fails the build.
ADR-0028 records the reasoning, including
what would make the whole approach wrong.
- Mounting resources — the server side of the same grammar
- Dart client — the same design where the language cannot narrow
- Go CLI — the same argument for a consumer with no compile step
- Capabilities — the declarations these types come from