A self-contained, TypeScript-based code generator that parses service files (written against a typed createApi builder) and emits:
- Per-service
QueryKeys.<service>.ts - Merged
models/QueryKeys.tswith aqueryKeysobject andQueryKeyType - Per-endpoint React Query hook files under
<service>/queries/and<service>/mutations/ - Service
index.tsbarrels + top-levelapi/index.ts
It replaces an older C++ pipeline with a single tsx-driven script that uses ts-morph for TypeScript parsing. No build step, no compiled binaries.
- What gets generated
- Prerequisites
- Directory layout
- Source-file conventions
- Pipeline stages
- Validation checkers
- Configuration file
- IR schema
- Integration checklist (new project)
- Adding a new service
- Adding a new checker or generator
- Troubleshooting
For each discovered service (e.g. src/shared/api/auth/Auth.service.ts):
src/shared/api/
auth/
QueryKeys.auth.ts ← per-service keys (auto-generated)
queries/
useTestQuery.auth.ts ← one file per non-mutation endpoint
useTestInfiniteQuery.auth.ts
...
index.ts ← barrel
mutations/
useTestMutation.auth.ts
...
index.ts ← barrel
index.ts ← barrel: models + queries + mutations
models/
QueryKeys.ts ← merges every *_QUERY_KEYS; exports queryKeys + QueryKeyType
index.ts ← top-level api/index.ts (auto-regenerated)
Every generated file is fully overwritten on each run — never hand-edit them.
The generated hooks import from these modules — they must exist in your target project with matching exports:
| Import path (from generated file) | Required exports |
|---|---|
../../createApi |
HookFn<TRequest, TResponse> type |
../../queryClient |
getQueryClient() |
../../hooks |
useQueryWithOptions, useInfiniteQueryWithOptions, useSuspenseQueryWithOptions, useSuspenseInfiniteQueryWithOptions, useQueriesWithOptions, usePrefetchQueryWithOptions, usePrefetchInfiniteQueryWithOptions, plus useMutationEvents, useQueryEvents (re-exported via top-level api/index.ts) |
../../models |
QueryError, QueryKeyType, queryKeys, and the typed param interfaces: UseQueryWithOptionsParams, UseInfiniteQueryWithOptionsParams, UseSuspenseQueryWithOptionsParams, UseSuspenseInfiniteQueryWithOptionsParams, UseQueriesWithOptionsParams, UsePrefetchQueryWithOptionsParams, QueryFetchParams, InfiniteQueryFetchParams, PrefetchInfiniteQueryFetchParams |
// package.json → scripts
{
"api-codegen": "tsx scripts/api-codegen/run.ts"
}Also requires a working yarn run lint:fix (or swap the command in run.ts) — the pipeline runs it after generation.
Copy this folder verbatim into the target project:
scripts/api-codegen/
run.ts ← entrypoint (orchestrator)
run-codegen.sh ← optional thin wrapper: `npx tsx run.ts`
parse.ts ← ts-morph parser → IR
checkers.ts ← validation rules
utils.ts ← shared string/path/IO helpers
ir-types.ts ← IR + CodegenConfig type definitions
codegen.config.json ← project-specific configuration
generators/
server-hooks.ts ← emits per-endpoint hook files
query-keys.ts ← emits per-service QueryKeys.<svc>.ts
merge-query-keys.ts ← emits models/QueryKeys.ts
update-index.ts ← emits top-level api/index.ts
ir.json is a debug artifact (full parsed IR). It's written next to run.ts on every run — gitignore it if you don't want it checked in.
The parser relies on naming and shape — no decorators or magic comments.
| Pattern | Meaning |
|---|---|
**/*.service.ts or **/*Service.ts |
Service definition (has createApi<T>()...) |
**/*.api.ts, **/models.ts |
Model files — exported interface / type declarations are indexed |
Configurable via codegen.config.json (globs).
Two accepted forms; both are detected:
// Classic
export const AuthService = createApi<AuthServiceAPI>()({
endpoints: builder => ({ ... }),
});
// Chained (with injected client)
export const AuthService = createApi<AuthServiceAPI>().configure({
client: axiosBaseQuery,
})({
endpoints: builder => ({ ... }),
});Hard requirements (enforced by checkers):
-
The interface passed as
createApi<T>must be declared in the same file. -
The endpoints lambda parameter must be named exactly
builder. -
Each endpoint property in the interface must be typed as
Promisify<TRequest, TResponse>(the parser literally regex-matchesPromisify<…,…>). -
Endpoints must use new-style call forms only:
builder.X(fn)— standardbuilder.X.hook(fn)— hook-style (lets the function use React hooks)builder.X.configure({ client })(fn)— per-endpoint client override
The legacy
builder.X(fn, options)form is rejected bycheckNoLegacyOptions.
Recognized: query, mutation, infiniteQuery, suspenseQuery, suspenseInfiniteQuery, queries, prefetch, prefetchInfiniteQuery.
Each kind gets a different hook template (see generators/server-hooks.ts). mutation goes under mutations/; everything else under queries/.
- The exported
constnext tocreateApi<T>()is the service constant (e.g.AuthService). - The service stem is derived from the file basename:
Auth.service.ts→auth;AuthService.ts→Auth. Lowercased where used. - Generated artifacts derive from the stem:
- File suffix:
.<stem>.ts(e.g.useTestQuery.auth.ts) - Key prefix (snake-upper):
TEST_QUERY_AUTH_SERVICE - Hook/fn prefix (pascal):
testQueryAuthService
- File suffix:
run.ts executes in order:
- Load config —
codegen.config.jsonvialoadCodegenConfig(). - Parse (ts-morph) —
buildIR(config)walks the matched source files and emitsIR. - Write IR — JSON dumped to
config.irOutput(debug artifact). - Run all checkers — any issue halts the pipeline with
process.exit(1)before any file is written. - Generate server hooks —
generators/server-hooks.ts. Deletes the existingqueries/andmutations/directories for each service first, then rewrites from scratch. - Generate per-service
QueryKeys.<svc>.ts—generators/query-keys.ts. - Generate merged
models/QueryKeys.ts—generators/merge-query-keys.ts. - Generate
api/index.ts—generators/update-index.ts(re-exports every direct subfolder ofapi/exceptmodelsandhooks). - Run
yarn run lint:fix— inherits stdio; a non-zero exit halts the script.
Defined in checkers.ts — runs before any file is written.
| Checker | Rejects |
|---|---|
checkUniqueTypes |
Same interface/type name declared in multiple model files |
checkBuilderName |
endpoints: arg => … where arg isn't named builder |
checkGenerics |
createApi<X> where X isn't defined in the same service file |
checkNoLegacyOptions |
builder.X(fn, { … }) — forces migration to configure/hook |
To add one, append to the CHECKERS tuple.
scripts/api-codegen/codegen.config.json:
{
"apiRoot": "src/shared/api",
"modelFilePatterns": ["**/*.api.ts", "**/models.ts"],
"serviceFilePatterns": ["**/*.service.ts", "**/*Service.ts"],
"irOutput": "scripts/api-codegen/ir.json"
}Paths are resolved against the project root (two levels up from scripts/api-codegen/).
apiRoot— where both the input services live and the generatedmodels/QueryKeys.tsandindex.tsare written.modelFilePatterns— globs relative toapiRoot. Each matched file's exportedinterfaces andtypes are indexed (feeds import resolution in generated hooks).serviceFilePatterns— globs relative toapiRoot. Each matched file is parsed for acreateApicall. Files matching both categories are treated as services only.irOutput— where to write the debug IR (relative to project root).
See ir-types.ts. Summary:
IR = {
version: 1,
apiRoot: string,
modelFiles: Array<{ path, declarations: [{ name, kind: 'interface' | 'type' }] }>,
services: Array<Service>,
}
Service = {
path: string, // project-relative, forward-slashed
folder: string, // immediate parent dir (e.g. "auth")
serviceStem: string, // e.g. "auth"
serviceConstName: string | null, // e.g. "AuthService"
apiInterfaceName: string | null,
apiInterfaceDefinedInFile: boolean,
builderParamName: string | null, // must be 'builder'
endpoints: Array<Endpoint>,
}
Endpoint = {
name, requestType, responseType, // strings, exactly as written in source
builderKind: BuilderKind | null,
callStyle: 'standard' | 'hook' | 'configure',
clientOverride: string | null, // text of the client override, if any
usesLegacyOptions: boolean,
}requestType === 'void' switches the hook template into its void variant (different signatures, no params).
- Copy
scripts/api-codegen/into the target repo. - Install deps:
yarn add -D ts-morph tsx. - Add script to
package.json:"api-codegen": "tsx scripts/api-codegen/run.ts". - Edit
codegen.config.jsonto point at your API root and patterns. - Port
createApi.ts(and theHookFntype) into<apiRoot>/createApi.ts. See this repo'ssrc/shared/api/createApi.ts— it's ~115 lines, no external deps. - Set up runtime modules under
<apiRoot>/:queryClient.ts→export const getQueryClient = () => <your QueryClient>hooks/→ wrapper hooks (useQueryWithOptions, etc.) over@tanstack/react-querymodels/→QueryError, theUse…Params/…FetchParamsparameter interfaces, plus the generatedQueryKeys.ts
- Rewrite any legacy services to the
builder.X(fn)/.hook(fn)/.configure({client})(fn)forms. Run the pipeline once —checkNoLegacyOptionsandcheckBuilderNamewill catch violations. - Gitignore
scripts/api-codegen/ir.jsonif you treat it purely as a debug artifact. - Wire into husky / CI (optional) if you want codegen to run on pre-commit or on CI before
tsc --noEmit.
- Create
src/shared/api/<name>/<Name>.service.ts(or<Name>Service.ts). - Declare the
*APIinterface withPromisify<Req, Res>properties. - Call
createApi<YourAPI>()…({ endpoints: builder => ({ … }) })and export the result. - Place shared model types in
models.tsor*.api.tsin the same or any scanned folder. - Run
yarn api-codegen. The new folder is populated withQueryKeys.<name>.ts,queries/,mutations/, and a barrelindex.ts. The mergedmodels/QueryKeys.tsand top-levelapi/index.tspick it up automatically.
Append to CHECKERS in checkers.ts:
export function checkMyRule(ir: IR): CheckResult {
const issues: string[] = [];
// inspect ir, push human-readable messages
return { name: 'check-my-rule', issues };
}
export const CHECKERS = [
checkUniqueTypes,
checkBuilderName,
checkGenerics,
checkNoLegacyOptions,
checkMyRule, // ← add
] as const;Drop generators/my-thing.ts with a generate(ir, projectRoot) export, then call it from run.ts in the order you want. Use utils.ts helpers (writeFile, rmIfExists, toImportSpecifier, the case converters, buildDeclarationIndex) to stay consistent with existing output.
| Symptom | Cause | Fix |
|---|---|---|
check-generics: createApi<X> but interface 'X' is not defined in the file |
Interface lives in another file or isn't exported | Move/define the interface in the same .service.ts |
check-builder-name: endpoints param is 'b', expected 'builder' |
Destructured or renamed parameter | Rename the param to builder |
check-no-legacy-options |
Using the old builder.X(fn, { client }) shape |
Migrate to builder.X.configure({ client })(fn) |
| A generated hook imports a type that doesn't exist | Response/request type head isn't an exported interface/type in a matched model file | Either export it from a scanned file, or verify your modelFilePatterns covers its location |
lint:fix failed |
Project's ESLint config can't parse the output | Run yarn lint manually against a single generated file to see the real diagnostic; often a missing type export in models/ |
| Nothing happens for a service | File name doesn't match serviceFilePatterns, or createApi<T> call wasn't found |
Check the file suffix and the exact createApi<…>() identifier |
| Endpoint is skipped silently | builderKind couldn't be resolved — usually a typo like builder.querys or a missing serviceConstName |
Confirm the builder method name and that the service is exported as a const |
Inspect scripts/api-codegen/ir.json after a run to see exactly what the parser extracted — most surprises become obvious there.