Extensions
Register integrations via extensions, and write your own.
Integrations aren't config flags — they're extensions you register in
extensions: [...]. An extension is a build-time object (usually a factory so it can take
options) that the host runs around the core discovery → IR → emit pipeline. Without any,
the generated client is a plain typed-fetch createApi(fetcher); each extension you add
shapes routes, emits extra files, or augments api.ts.
import { NestjsCodegenModule } from '@dudousxd/nestjs-codegen/nest';
import { tanstackQuery } from '@dudousxd/nestjs-codegen-tanstack';
import { nestjsFilterCodegen } from '@dudousxd/nestjs-filter-codegen';
NestjsCodegenModule.forRoot({
contracts: { glob: 'src/**/*.controller.ts' },
codegen: { outDir: 'src/generated' },
extensions: [tanstackQuery(), nestjsFilterCodegen()],
});Extensions run in registration order. The same array works in
nestjs-codegen.config.ts (defineConfig) for CLI/CI runs — keep one source of truth and
import it into forRoot().
Companion extensions
| Extension | Package | What it adds |
|---|---|---|
tanstackQuery() | @dudousxd/nestjs-codegen-tanstack | Wraps each leaf into a handle exposing queryOptions/mutationOptions/infiniteQueryOptions/queryKey. See TanStack Query. |
nestjsFilterCodegen() | @dudousxd/nestjs-filter-codegen | Adds a typed filterQuery() member to leaves whose route is decorated with @ApplyFilter/@FilterFor. See nestjs-filter. |
nestjsInertiaCodegen() | @dudousxd/nestjs-inertia-codegen-extension | Adds the Inertia router import and a typed navigate() helper to api.ts, plus opt-in shared.ts and page-excludes.ts emission. See nestjs-inertia. |
Write your own
The extension contract is published from @dudousxd/nestjs-codegen/extension. Author one
with defineExtension (an identity helper for type inference) and return it from a
factory so callers can pass options:
import { defineExtension } from '@dudousxd/nestjs-codegen/extension';
export function myExtension() {
return defineExtension({
name: 'my-extension',
apiHeader(ctx) {
return { imports: ["import { thing } from 'my-lib';"] };
},
});
}A CodegenExtension has a unique name and any of these hooks. They split into multi
hooks (every extension runs; results accumulate or chain) and single-slot hooks (at
most one extension may claim each — two claimers is a hard error):
transformRoutes(routes, ctx)— mutate/augment the route IR before emit (chained in registration order). e.g. the filter extension attachesfilterFieldsto matching routes.emitFiles(ctx)— contribute extra output files (paths relative tooutDir; a path claimed twice throws). e.g. Inertia page discovery emittingpages.d.ts/components.json.apiHeader(ctx)— contribute top-levelapi.tsimports + statements (imports deduped by the host). e.g. the Inertiarouterimport andnavigate()helper.apiMembers(leaf, ctx)— add named members to a handle leaf (only when a client layer is active; name collisions across extensions throw). e.g. the filterfilterQuery.apiTransport(single-slot) — claim how an endpoint issues its request; unset falls back to the neutral fetcher transport.apiClientLayer(single-slot) — claim what a leaf returns; unset leaves a bare callable returning aPromise. e.g. TanStack wraps each leaf into a handle.
Every hook receives a read-only ExtensionContext (cwd, outDir, routes, config,
and two lazily-created shared ts-morph projects).
Which project to ask for
ctx.project()— bare compiler options, nopaths. Fine for reading files you already have a path to.ctx.tsconfigProject?.()— built from the consumer's tsconfig (app.tsconfig, else<cwd>/tsconfig.json), sopathsaliases resolve. Use it whenever you follow an import from a file you were given to a declaration you need to read — a controller's@ApplyFilter(SomeFilter)target imported as@/api/…only resolves through this one.
Do not build your own from tsConfigFilePath. Parsing a tsconfig also resolves its
FILE LIST, and a tsconfig with no include walks the whole project root — one unreadable
directory (a docker bind mount a container chowned to its own UID) throws
EACCES … scandir, and the usual catch around it falls back to a project with no
paths, so aliased targets silently resolve to nothing. The host already loaded it safely;
share it.
It is typed optional because your extension may run against an older host, so keep a fallback:
const project = ctx.tsconfigProject?.() ?? myOwnFallback(ctx);The extension contract is semver 0.x — the shape may change until 1.0. Out-of-repo
extensions should pin a compatible @dudousxd/nestjs-codegen peer range.