- Status: Accepted, implemented in M1 (2026-07-17)
- Date: 2026-07-16
- Related: 01-architecture.md (current state)
main's API contract between services/api and apps/website is built from three cooperating pieces:
- An abstract
Route<TResult, TBodyOrQueryZodType>class (services/api/src/routes/route.ts) that every endpoint subclasses, carrying a phantom field purely to smuggle generics through:public readonly __internalOnlyHereForTypeInferrenceDoNotUse__!: { bodyOrQuery: z.infer<TBodyOrQueryZodType>; result: TResult; };
- Type-level reflection over the route classes' value exports (
services/api/src/routes/routes.tsre-exports classes as values;services/api/src/routes/_types/routeTypes.tsreflects overtypeof routesto buildRoutesByClass→RoutesByPath→APIRoutes). - A hand-maintained mirror on the frontend (
apps/website/src/data/common.ts'sroutesInfoobject) duplicating every route's path string, params, and query shape, used to build fetches and react-query keys.
- Multiple sources of truth. A route's path/params/query is declared in the
Routesubclass'sinfo, and separately by hand inroutesInfo. Path strings (e.g./v3/guilds/:guildId/ama/amas) must be character-identical between the two or theNarrow<...>type utility silently resolves tonever— a typo doesn't error loudly, it just erases the type. - Adding one endpoint touches ~6 files: the route file,
routes.ts,_types/index.ts,common.ts,client.tsx, and oftenserver.ts. - The inference doesn't actually hold at the call sites. Both
apps/website/src/data/client.tsxanddata/server.tscontain:and// @ts-expect-error - This won't ever compile const data = (await fetcher()) as Promise<InferAPIRouteResult<Options['path'], 'GET'> | null>;
So the elaborate generic machinery (// @ts-expect-error - We can't get it to compile on the Method InferAPIRouteResult<Options['path'], Method>,
ParseHTTPParameters,InferRouteResult,ConstructorToType,Narrow) is defeated by casts at the exact points where it's supposed to deliver value. You pay the complexity cost without the safety guarantee. - A route can't validate both body and query. The single
TBodyOrQueryZodTypegeneric meansregister()literallythrows if bothbodyValidationSchemaandqueryValidationSchemaare set — a runtime check standing in for a type constraint. - Frontend build is coupled to backend build. The website typechecks against
@chatsift/api's compileddist/index.d.ts, so the API must build first, and the "contract" is really just importing the backend's internal types wholesale. - Duplicated fetch/token logic. Client (
data/client.tsx) and server (data/server.ts) reimplement fetching, access-token-rotation-header handling, and path substitution independently, with two different token stores (useStateon the client, a module-levelMapon the server) — easy to let drift.
| Option | Verdict |
|---|---|
| Status quo, just remove the casts | Not viable — the casts exist because the inference doesn't hold across the method/path indirection; removing them without redesigning the type flow just breaks the build. |
| tRPC | Would give real end-to-end inference, but requires restructuring services/api around tRPC's router/procedure model — a bigger-than-necessary framework swap for what's fundamentally a typing problem, and it doesn't naturally fit polka's existing route-per-file layout. |
| ts-rest | Contract-first, close to what's wanted, but introduces a separate contract-definition DSL and package layer that duplicates work already achievable with plain TypeScript generics. |
| OpenAPI + codegen | Adds a build step (spec generation + client codegen) for a monorepo where frontend and backend already share a TypeScript boundary — unnecessary indirection. |
defineRoute factory (SimplyChords pattern) — chosen |
A functional factory whose only job is to preserve literal/inferred generics (method, path, body, query, params, response inferred from the handler's return type). No phantom fields, no reflection over value exports, no separate DSL. The API package is consumed by the frontend as a type-only workspace dependency (devDependency, import type only) — zero extra runtime, zero client bundle cost, and the "contract" is just InferRouteContract<typeof someRoute>. |
Adopt the SimplyChords defineRoute / InferRouteContract pattern, detailed with code in 01-architecture.md. Concretely:
- Replace
services/api/src/routes/route.ts'sRouteclass withservices/api/src/core/route.ts'sdefineRoutefactory +defineMiddlewarefor typed middleware context (req.identityetc. via aUnionToIntersectionmerge over declared middleware). - Replace
routes.ts+_types/*with a singleservices/api/src/core/contract.ts(InferRouteContract) and a type-onlyservices/api/src/index.tsthat re-exports route objects + their zod schemas (so the frontend can reuse the same schema for form validation). - Delete
apps/website/src/data/{common,client,server}.tsxandutils/fetcher.tsx; replace withapps/website/src/api/{fetch,queryClient,error,token}.ts(isomorphicapiFetchthat branches ontypeof window) + oneapps/website/src/api/routes/<resource>.tsfile per resource exporting hooks and prefetch-friendly{ queryKey, queryFn }objects, with all types derived viaInferRouteContract<typeof route>— no hand-mirrored path registry. - A
mountRoute(route)pipeline (services/api/src/core/server.ts) replacesRoute.register(): conditionally parses JSON, validates body/query/params via the route's zod schema (throwing a Boom 400 witherror.issueson failure), runs the route's middleware, then serializes the handler's return (200 + JSON, or 204 forundefined/null) — removing the repeated manualres.statusCode = 200; res.setHeader(...); res.end(JSON.stringify(...))boilerplate from every handler.
- Positive: one source of truth per route (the
defineRoutecall); real compiler-enforced end-to-end types with zero@ts-expect-error; adding an endpoint touches 2 files (the route file + its one-line re-export inindex.ts) instead of ~6; body+query+params can all be validated on the same route; response schemas can be reused for docs/validation later if wanted. - Negative / accepted tradeoffs: the response contract is inferred from the handler's return type, not runtime-validated against
schema.response(matching SimplyChords — the response schema is documentation + type source, not a runtime gate; can be added later if desired). Path strings are still passed as literal strings toapiFetchon the frontend (not structurally checked againstroute.path) — same residual risk as today, but isolated to one call site per hook instead of a whole mirrored registry. - Migration cost: all ~13 existing routes (5 AMA, 4 auth, 4 guilds) needed porting; done in M1 (#128–130) — see 01-architecture.md §1.