Treating Queries as a First-Class Citizen
Type safety, portability and testability from one definition.
A modern frontend is mostly queries. At Xelix our platform consumes hundreds of REST API endpoints in this way: lists, detail views, aggregates, permission checks. These are ever-growing as more features are added.
Without careful attention and strong abstractions, queries have a habit of degrading. Each one becomes a small pile of decisions made in isolation, from how the cache key is constructed, to how long the data stays fresh, how the response is transformed, what happens in the case of an error. Multiply this by a few hundred, and four problems start to appear reliably:
They become complex. A query is rarely just a fetch, it needs a key, stale time, and methods for transforming the shape in a way the UI requires. Scatter this across multiple call-sites, and it’s easy for the same endpoint to be over-consumed in multiple slightly different ways.
They become unsafe. The data type is whatever the developer believed it was when it was coded, which can drift over time with schema updates. And the error type is often unknown because the query throws and TypeScript gives up. If a field is changed on the API, the frontend build passes and we don’t know until runtime.
They get called too often. Cache keys drift by a character, 2 slightly different call-sites for the same endpoint become out of sync and an endpoint is called twice.
They de-synchronise across application layers. A route guard needs to make a decision from query data. You already have a useFooQuery hook, but it cannot run outside React, so you lift the query function and call queryClient.fetch(). Options and cache keys can now drift independently: overfetching at best, cache de-synchronisation at worst, when a mutation invalidates only one of the two call-sites.
None of this is exotic, it’s the default outcome of treating queries as something we write, rather than something defined. Each problem can be solved in isolation using type schema tooling, query key structuring or tooling such as react-query-kit.
But we wanted something more robust. A query in our codebase is a first-class object; a single definition which has its own endpoint, types, cache policy. And can be handed to a react component, router middleware or even MSW for type safe integration test mocking.
Three things which make the approach work:
Type safety from the backend. Our Django API publishes an OpenAPI schema, we generate TypeScript from it. A query can be declared with a path, nothing more. Parameters, response shape and per-status error shapes are all derived. Nobody handwrites API types any more.
Queries are objects, not hooks. The definition is an object, not a hook. Our createQuery factory exposes a use() for React, fetch() for imperative call-sites, and execute() for anywhere which needs to access the underlying query function without interacting with the store. The key, transformers, and default arguments live on the object, so every call-site shares them by default.
Testing that reads the object. Since our objects have a known shape and endpoint, we can utilize this for type safety in mocking. Tests no longer know about endpoints, instead we can call mockQueryWithData(query, data) which will enforce type safety and return a type safe MSW handler.
Individually these are conveniences. Together they are the point: one definition, three consumers, one source of truth for what the data is.
Pillar 1 - Type safety from the backend
Modern BFF frameworks enable end to end type safety, but with separate frontend/backend repositories this becomes more difficult, and in many cases, teams fall back to hand writing types; someone reads the endpoint, writes an interface based on what they see and moves on. At best this is correct until someone updates the endpoint, but in many cases subtleties are missed, engineers will rarely write a discriminated union of all possible argument/response code permutations.
Our backend is Django REST Framework, and leverages drf-spectacular to introspect serialisers and publish an OpenAPI schema. That schema is the most honest description of the API, so we opted to make it the source of our types.
The pipeline for this is deliberately boring to reduce any magic, we want a like-for-like TypeScript equivalent of the schema.
Django + drf-spectacular -> schema.yaml -> openapi-typescript -> schema.d.ts
On backend merge, a repository-dispatch event fires into the frontend repo, which regenerates the schema and opens a pull request. Nobody syncs types by hand, because forgetting is not an option. When nothing breaks, the PR passes CI and can merge without intervention. When a field is renamed, CI fails on the schema PR, and we know before the frontend ships against the new API.
But this isn’t useful on its own. Naturally, the schema output is huge and unusable directly. Ours is roughly 2MB and 60,000 lines, the shape is a deeply nested map of paths, methods, status codes and content types.
Reading a response type looks something like:
type Invoice = paths['/invoice/{id}/']['get']['responses'][200]['content']['application/json']
It’s technically type safe, but practically unusable once you factor in permutations for status codes.
So the generated file is not what the codebase consumes. On top of this is a small layer of type utilities, a few hundred lines which make the schema easier to address.
Which endpoints support a method:
export type PathsWith<T extends HttpMethod> = { /* maps over paths, drops never/undefined methods */ };
type GetEndpoint = PathsWith<'get'>; // every path with a GET
type PostEndpoint = PathsWith<'post'>; // every path with a POST
What endpoints return, split by outcome
export type ResponseType<Endpoint, Method, Response> = { /* Pulls response type from the schema */}
export type SuccessCode = 200 | 201 | 202 | 203 | 204;
export type ErrorCode = 400 | 401 | 403 | 404 | 409 | 500 | 502 | 503 | 504;
export type SuccessResponseContent<Endpoint, Method> = ResponseType<Endpoint, Method, SuccessCode>;
export type ErrorResponseContent<Endpoint, Method> = ResponseType<Endpoint, Method, ErrorCode>;
That split matters more than it looks. A 400 from one endpoint and a 400 from another are different types. Most codebases throw that information away. It comes back in pillar two.
Path parameters, derived from the path.
export type ExtractPathParams<S extends string> = string extends S
? string[]
: S extends ''
? []
: S extends `${string}{${infer D}}${infer U}`
? [D, ...ExtractPathParams<U>]
: [];
type P = ExtractPathParams<'foo/{bar}/{baz}'>; // ['bar', 'baz']
export type EndpointPathParams<Endpoint> = Record<ExtractPathParams<Endpoint>[number], string | number>;
A recursive template literal type walks the path, pulls out every {segment}, and produces the params object. Instead of being a string to be interpolated, the URL is a type we read.
The path is the only thing you need to write.
Put that together and declaring a query needs one piece of information:
export const invoiceByIdQuery = createEndpointQuery('/invoice/{id}/', {
getQueryKey: (args) => ['invoice', 'by-id', args] // args: {id: string | number}
})
From that path, everything is derived. Args requires an ID, parameters are based on the schema, the success type is a 2xx body, and the error type is a PlatformAPIError narrowed to the endpoint and method, with per status methods (eg isBadRequestError() , isForbiddenError()) to narrow further.
Nobody hand writes types any more, more importantly, nobody can. There is no interface to drift.
The payoff isn't just that responses are typed. It is that the endpoint path becomes an identifier, one string that the type system can resolve into parameters, responses, and errors. Which is exactly what makes the next part possible: if a path is enough to describe a query, a query can be a single object that carries it.
Pillar 2 - Queries are objects, not hooks
A typed path is only useful if every call-site actually uses that type. The usual way to share a query in a React app is to export a hook:
export const useInvoiceById = (id: number) =>
useQuery({
queryKey: ['invoice', 'by-id', id],
queryFn: () => fetchInvoice(id),
staleTime: 60_000,
})
That solves the component. It does not solve the route guard. Hooks cannot run in middleware, so the next person lifts queryFn and queryKey out, calls queryClient.fetchQuery(), and now there are two definitions. Keys drift. staleTime lives on one call-site and not the other. A mutation invalidates ['invoice', id] and misses ['invoice', 'by-id', id]. This is problem four from the intro, in code.
So we stopped exporting the hook as the definition. The definition is an object. The hook is a method on it.
export const invoiceByIdQuery = createEndpointQuery('/invoice/{id}/', {
getQueryKey: (args) => ['invoice', 'by-id', args],
defaultOptions: { staleTime: 60_000 },
})
createEndpointQuery is a thin wrapper around a more generic createQuery. The generic factory does not know about HTTP. It knows about four things: how to run the work, how to key the cache, how to shape success, how to shape failure. The endpoint wrapper fills those in from the path we typed in pillar one, and stashes the path on metadata so tests can read it later.
What you get back is a value:
invoiceByIdQuery.use({ id }) // React
invoiceByIdQuery.fetch({ id }, { staleTime: 60_000 }, queryClient) // router, loaders
invoiceByIdQuery.execute({ id }, ctx) // Result, no store
invoiceByIdQuery.getQueryKey({ id }) // ['invoice', 'by-id', { id }]
The key, the stale time, the transformers, the endpoint — they live once. A component and a middleware that both call this object cannot disagree about the cache identity, because there is only one.
Passing the object around
Because it is a value, you can type other functions over it. A settings route that should redirect unless some flag is on does not take a queryFn. It takes the query:
createRedirectMiddleware({
query: accountQuery,
isAllowed: (account) => account.isFooEnabled
})
createRedirectMiddleware is generic over Query<Args, ..., Success>. It calls query.fetch(...), and isAllowed receives the same success type a component would get from .use(). The guard and the page share the cache. If the page has already fetched, the middleware is a cache read. If the middleware runs first, the page hydrates from cache. That is the whole point of treating the query as a first-class citizen: you can hand it to anything that knows the Query shape.
A hook cannot do this. You can pass useInvoiceById into a function, but the second you call it outside a component React is unhappy, and the type of that function cannot say “give me something fetchable.” The object can.
Why not queryOptions or react-query-kit?
TanStack Query already has a blessed version of “define it once”: queryOptions(). You put queryKey and queryFn in an object, spread it into useQuery in the component and queryClient.fetchQuery in the loader. That is the right instinct. It is still a bag of options. Every call-site wires the hook or the client itself. There is no .use(), no .fetch(), no way to say createRedirectMiddleware({ query }) without each consumer knowing TanStack’s API.
react-query-kit is closer. createQuery returns something you call as a hook, with .getKey() and .getFetchOptions() attached. The primary identity is still the hook. Extra methods exist so SSR can reach the key. Passing that value into a typed middleware factory is awkward, because the thing in your hand is a hook.
We flipped it. The object is the API. .use() is how React consumes it. That sounds small. It is the difference between colocation and a type you can constrain.
Success and error are both data
TanStack Query’s contract is throw-based. If queryFn throws, the query is in error. If it returns, it succeeded. That is why error in useQuery so often collapses to unknown , the type came out of a catch.
Our queryFn does not throw. It returns a discriminated result:
type TryCatchResult<T, E> =
| { data: T; error: null }
| { data: null; error: E }
The HTTP client already produces that, wrapping failures in PlatformAPIError<Endpoint, Method>. execute() stays in result-land and runs formatData / formatError. Only executeOrThrow() throws, a one-line adapter so React Query can still do its job.
.use() and .fetch() go through that adapter. .execute() does not. Need the data without touching the cache? Call execute. Need it in the cache? Call fetch or use. Same object, two error styles, one of them chosen on purpose.
The type trick is that we read the error off execute, not off a catch:
type QueryError<T> = NonNullable<Awaited<ReturnType<T['execute']>>['error']>
type QuerySuccess<T> = NonNullable<Awaited<ReturnType<T['execute']>>['data']>
QueryError<typeofinvoiceByIdQuery> is PlatformAPIError<'invoice/{id}/', 'get'>. The per-status guards from pillar one ( isBadRequestError(), isForbiddenError(), …) work on it. The schema’s error-body split survived the trip from OpenAPI, through the client, into the query object, and out into a component or a guard.
formatData is the same seam on the success side. Generated types describe the wire. If the UI wants a domain object, it is one function on the definition, not a transform copied across call-sites:
export const invoiceByIdQuery = createEndpointQuery('/invoice/{id}/', {
getQueryKey: (args) => ['invoice', 'by-id', args],
formatData: toDto(InvoiceDTO),
defaultOptions: { staleTime: 60_000 },
})
What this actually buys
Complex queries stop being a pile of decisions at the call-site. The call-site picks .use or .fetch and passes args.
Unsafe queries stop being possible in the usual way: args, success, and error are all derived from the path the object already carries.
Overfetching from drifted keys stops because there is one getQueryKey.
De-synchronisation across layers stops because the router does not get a cousin of the hook. It gets the same object.
The last piece is that this object knows its own endpoint. metadata is not documentation. This is how the next section mocks the network by passing the query in, instead of a URL string.
Pillar 3 - Testing that reads the object
Type-safe queries still die in tests if the mock is a URL and a blob of JSON. The test re-declares the contract the query already owns. A typo in the path, and you are not testing the query — you are testing a different endpoint that happens to 200 with faker data, while the real one 404s. The payload is any. Schema updates do not fail the test.
That is the same drift as handwriting types, one layer down.
Because the query object already carries its endpoint (metadata.endpoint, set when we called createEndpointQuery('/invoice/{id}/',...)), the test does not need a path. It needs the object.
// before — the test knows the URL, the payload is untyped
server.use(
http.get('*/invoice/:id/', () =>
HttpResponse.json({ id: 1, status: 'open' }),
),
)
// after — the test knows the query
server.use(
mockQueryWithData(invoiceByIdQuery, {
id: 1,
status: 'open',
}),
)
mockQueryWithData pulls the path off the query, builds an MSW handler for that GET, and type-checks data against the 200 body for that endpoint. If the schema drops status, this test does not compile. The mock and the query cannot disagree about which URL they mean, because there is only one source.
You can still supply a function when the response depends on the request — pagination, search, a specific id — without going back to a string path:
server.use(
mockQueryWithData(invoiceByIdQuery, async ({ params }) => ({
id: Number(params.id),
status: 'open',
})),
)
Errors use the same helper. Pass a status, and the payload type switches to that status’s body from the schema:
server.use(
mockQueryWithData(invoiceByIdQuery, 404, { detail: 'Not found' }),
)
That is pillar one’s ErrorResponseContent showing up in a test. Per-status error shapes were not a party trick for ErrorResponseContentisNotFoundError(). They are what makes a 404 mock typed.
Tests should not mention endpoints
Once this exists, domain test helpers stop looking like router tables. They look like the queries:
export const invoiceByIdWithData = (data: Partial<Invoice> = {}) =>
mockQueryWithData(invoiceByIdQuery, createMockInvoice(data))
export const invoiceByIdNotFound = () =>
mockQueryWithData(invoiceByIdQuery, 404, { detail: 'Not found' })
An integration spec bootstraps a page by composing those helpers. If the page grows a second query, you pass a second object. You do not go hunting for which URL string the component happened to hit.
That is the same identifier, consumed by a component, a guard, and now a test.
Three mechanisms, one bet: define the query once, then pass it around.
The path is the identifier. The object is the API. The test reads the object back. Type safety is not a generated file we peek at. Portability is not a hook with extra methods. Testability is not a URL we hope matches production. They are the same definition, seen from three sides.
That is what “first-class citizen” means here. Components, route middleware, and MSW are consumers. None of them owns the query.
Queries will keep multiplying. The question is whether each one is a pile of decisions, or a thing you can point at.