API Client
The createApi(fetcher) factory and how to use it.
api.ts exports a factory, Tuyau-style: you inject the fetcher at runtime instead
of the codegen hardcoding an import path. This is what makes the client portable — you
control the transport, base URL, auth, and serialization.
import { createApi } from '../generated/api';
import { createFetcher } from '@dudousxd/nestjs-client';
export const api = createApi(
createFetcher({ baseUrl: '/api', headers: () => ({ authorization: token() }) }),
);The result is a nested object keyed by route name (controller.method), fully typed
from your contracts.
Calling endpoints
Each endpoint is an awaitable handle — call it with your typed params/query/body
and await it to perform the request (Tuyau-style). Params, query, and body are all typed
from your contracts:
import { api } from '../lib/api';
// GET /users → Promise<User[]>
const users = await api.users.list();
// GET /users/:id → params are typed + required
const user = await api.users.show({ params: { id } });
// POST /users → body typed from the DTO
const created = await api.users.create({ body: { email: 'a@b.com', password: 'secret123' } });The handle is a real Promise (typed RequestHandle<Response>, which is
Promise<Response> & { fetch(): Promise<Response> } plus any extension members), so it goes
anywhere a promise does — Promise.all, a function typed Promise<T>, a TanStack
mutationFn/queryFn — without calling .fetch() first:
const [users, count] = await Promise.all([api.users.list(), api.users.count()]);
useMutation({ mutationFn: (body: Route.Body<'users.create'>) => api.users.create({ body }) });It is lazy: nothing is sent until it is awaited (or .then/.catch/.finally is
called), so building queryOptions() or reading queryKey() on a handle never fires a
request. Awaiting the same handle twice is memoized (one network call); .fetch() always
sends a fresh one.
Strict compiler settings
The generated api.ts compiles cleanly under strict plus noUnusedLocals,
noUnusedParameters, exactOptionalPropertyTypes, noImplicitOverride,
noUncheckedIndexedAccess and noPropertyAccessFromIndexSignature (a fixture test compiles
it with all of them on), so an app that type-checks the generated file doesn't have to loosen
its own tsconfig. It imports from routes.ts only the names it uses.
Schema pipes (@Body(new ZodPipe(schema)))
A route doesn't need @ApplyContract or a DTO class to be typed. When it validates with a
schema pipe, the pipe's argument is the schema, and the request types come from it:
const createBody = z.object({
email: z.string().email(),
plan: z.enum(['free', 'pro']).default('free'),
});
const listQuery = z.object({ page: z.coerce.number().int().min(1).default(1) });
@Post()
create(@Body(new ZodPipe(createBody)) body: z.infer<typeof createBody>) { /* … */ }
@Get()
list(@Query(new ZodPipe(listQuery)) query: z.infer<typeof listQuery>) { /* … */ }
@Get(':id/:status')
show(
@Param('id', new ZodPipe(z.string().uuid())) id: string,
@Param('status', new ZodPipe(z.enum(['open', 'closed']))) status: 'open' | 'closed',
) { /* … */ }await api.users.create({ body: { email: 'a@b.com' } }); // plan may be omitted
await api.users.list({ query: { page: 2 } });
await api.users.show({ params: { id, status: 'open' } }); // status: 'open' | 'closed'- Any pipe, any library.
new X(schema)or a factory callx(schema)counts, whatever the pipe class is called. What makes an argument a schema is its type: the TypeScript checker recognizes anything implementing Standard Schema (zod ≥ 3.24, valibot ≥ 1, arktype ≥ 2), so a valibot or arktype pipe works the same way. Pipes whose arguments aren't schemas (ParseIntPipe,new DefaultValuePipe(1),new ValidationPipe({ … })) are left alone, and the parameter's own type is read as before. - Where it applies. A whole
@Body(pipe), a whole@Query(pipe)or a named@Query('name', pipe), and path params through@Param('name', pipe)or a whole@Param(pipe)whose schema is an object. - The schema can live anywhere: declared next to the controller, imported from another file,
written inline, or returned by a helper (
new ZodPipe(pick(contract.body))).
Refinements, defaults and transforms
The types are the checker's own inference, not a walk over the method chain, so every
refinement resolves to the type it refines: .email(), .url(), .uuid(), .regex(),
.trim(), .min()/.max(), .refine(), .superRefine(), .brand(), zod 4's z.email()
and friends. The same inference types defineContract schemas, which no longer need to be kept
free of refinements.
A request is typed by what the schema accepts — its input type — because that is what the client sends:
| Schema | Request type |
|---|---|
z.number().optional().default(20) | limit?: number | undefined — may be omitted |
z.string().nullable() | string | null |
z.string().transform((s) => s.split(',')) | string — what the transform reads |
z.coerce.number() | number (zod 4 types its input as unknown; the output stands in) |
A defineContract response is typed by what the schema produces — its output type — since
that is what comes back.
The checker needs the schema library to be resolvable from your project (it is, if the app compiles). If it isn't, zod schemas fall back to the syntactic reader, which knows the core zod calls but not refinements.
HEAD, OPTIONS and @All() routes
@Head() and @Options() handlers get a client method like any verb; they call
fetcher.head / fetcher.options (head resolves to undefined — a HEAD response has no
body). These need @dudousxd/nestjs-client 0.9 or later, or a custom fetcher that has both
methods.
An @All() handler gets no client method. It answers every verb, so there is no single
request a typed method could issue, and in practice it's a protocol endpoint — an MCP
transport, a proxy, a webhook sink — driven by a dedicated client rather than by your UI. It
keeps its entry in routes.ts, so the URL is still one call away:
import { route } from '../generated/routes';
await fetch(route('mcp.handle'), { method: 'POST', body });File uploads (multipart/form-data)
A handler that accepts @UploadedFile() / @UploadedFiles() behind a Multer interceptor is
discovered as a multipart route. The uploaded-file field(s) are merged into the route's
body type — File | Blob in the browser, never the server-side Express.Multer.File —
and the generated call passes multipart: true to the fetcher, which serializes body as
FormData instead of JSON:
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
uploadAvatar(@UploadedFile() file: Express.Multer.File, @Body() body: UpdateAvatarDto) { /* … */ }const updated = await api.users.uploadAvatar({
body: { ...fields, file: selectedFile }, // sent as FormData, not JSON
});The field name(s) and arity come from the Multer interceptor:
| Interceptor | Body field |
|---|---|
FileInterceptor('file') | file: File | Blob |
FilesInterceptor('files') | files: Array<File | Blob> |
FileFieldsInterceptor([{ name: 'a' }, { name: 'b' }]) | a: Array<File | Blob>; b: Array<File | Blob> |
AnyFilesInterceptor() | flagged multipart (no statically known field names) |
A bare @UploadedFile() with no @Body() DTO at all is still a fully typed multipart
route — the uploaded field alone is enough for the generated call to require and send a body.
Each own-property of body becomes a form field: scalars as strings, Date as ISO,
File/Blob values (and arrays of them) as file parts. fetcher.fetchRaw/fetchBlob
accept onUploadProgress if you need upload progress — honored by axiosTransport,
ignored by the native-fetch transport.
Binary responses (downloads)
A handler that returns NestJS's StreamableFile, a Node Buffer, or Promise<StreamableFile>
is discovered as a binary response. Its response type is never wrapped in
Jsonify<...> — instead it's RawResponse<Blob>, and the call goes through
fetcher.fetchBlob(...) instead of the verb method, so you get back { data, status, headers }
(data is a Blob; headers lets you read content-disposition for the original filename):
@Get(':id/download')
download(@Param('id') id: string): StreamableFile { /* … */ }const { data, headers } = await api.files.download({ params: { id } });
// data: Blob — headers['content-disposition'] carries the filename.Binary routes work on any HTTP method — fetchBlob defaults to GET, so a binary route on
another verb passes its method explicitly. Observable/ReadableStream handlers are
unaffected — they stay on the existing SSE/stream path (fetcher.sse).
A binary GET route still gets queryOptions() from the
TanStack extension, but never infiniteQueryOptions() —
a download isn't paginated data.
Response types & serialization
By default the generated response type reflects the JSON wire shape, not the
in-process server type. A controller that returns a Date doesn't send a Date — it
sends the ISO string Date.prototype.toJSON() produces — so the client awaits a
string. The codegen wraps every response type in the type-only Jsonify<...> utility
from @dudousxd/nestjs-client to model exactly what JSON.parse(JSON.stringify(value))
gives you back:
// Controller: returns { id: string; createdAt: Date }
const user = await api.users.show({ params: { id } });
// user.createdAt is `string`, not `Date` — matching the actual JSON response.Jsonify transforms what JSON genuinely changes and leaves everything else alone:
Date(and anything with atoJSON()) → its serialized return shape.- Arrays and plain objects recurse; optional properties stay optional.
- Non-serializable properties (methods,
symbol,undefined-only) are dropped, andbigint(whichJSON.stringifythrows on) maps tonever. any/unknownpass straight through.
This only affects the response type — body, query, params, and error are
emitted as-is. It's a compile-time type transform with no runtime cost: nothing in
your client code changes, the types just stop lying about Date.
Opt out for superjson clients
If you deserialize responses with superjson,
Date/Map/Set/BigInt are revived on the client, so the raw controller return
type is already correct and Jsonify would be wrong. Set serialization: 'superjson' in
your config to emit the raw types unchanged:
import { defineConfig } from '@dudousxd/nestjs-codegen';
export default defineConfig({
// ...
serialization: 'superjson', // emit raw controller return types (revived on the client)
});Pair this with the runtime superjson hook described in Fetcher & Transports.
TanStack Query helpers
Register the tanstackQuery() extension and the same handle
additionally carries .queryKey(), .queryOptions() (GET) / .mutationOptions() (the rest),
and .infiniteQueryOptions() (GET) — from your TanStack adapter. The call still awaits:
import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
const direct = await api.users.show({ params: { id } }); // still a plain request
const user = useQuery(api.users.show({ params: { id } }).queryOptions());
const list = useInfiniteQuery(api.users.list().infiniteQueryOptions());
const create = useMutation(api.users.create().mutationOptions());
create.mutate({ body: { email: 'a@b.com', password: 'secret123' } });Inferring types
The generated Api type and the per-route response/body types are available without
calling anything:
import type { Api } from '../generated/api';
// the leaf returns an awaitable handle; the (already Jsonify-shaped) response type is:
// Awaited<ReturnType<typeof api.users.show>>Inertia navigation
Register the nestjsInertiaCodegen() extension and the generated
client additionally exposes a typed navigate() helper backed by the Inertia router,
so links and mutations can drive Inertia visits. See nestjs-inertia.