Aviary
Client

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.

src/lib/api.ts
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' } });

Awaiting the same handle twice is memoized (one network call). It also exposes .fetch()/.catch()/.finally() if you'd rather not await directly.

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:

Controller
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
uploadAvatar(@UploadedFile() file: Express.Multer.File, @Body() body: UpdateAvatarDto) { /* … */ }
Client
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:

InterceptorBody 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):

Controller
@Get(':id/download')
download(@Param('id') id: string): StreamableFile { /* … */ }
Client
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 a toJSON()) → its serialized return shape.
  • Arrays and plain objects recurse; optional properties stay optional.
  • Non-serializable properties (methods, symbol, undefined-only) are dropped, and bigint (which JSON.stringify throws on) maps to never.
  • any / unknown pass 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:

nestjs-codegen.config.ts
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.

On this page