Typed client with codegen
Generate a fully typed HTTP client for the inbox API from your NestJS controllers using nestjs-codegen — no hand-written fetch calls, no drift between server and client.
The React widget ships a hand-written NotificationsClient. If you'd
rather generate the client — and keep it in lockstep with your routes — point
@dudousxd/nestjs-codegen at your inbox
controller. It reads your controllers by static analysis and emits a typed api.ts + routes.ts.
The runnable examples/basic
app has the whole setup wired; this recipe walks through it.
1. A static, decorated controller
Codegen discovers routes by static AST — it reads top-level @Controller classes with their
@Get/@Post/… methods, the @Query()/@Param()/@Body() param types, and each method's return
type. (The library's createNotificationsController factory builds its controller at runtime, so it
isn't statically visible — write a plain decorated controller for codegen to read.)
@Controller('notifications')
export class NotificationsInboxController {
constructor(private readonly notifications: NotificationsQueryService) {}
@Get()
async list(@Query() query: ListNotificationsQueryDto): Promise<PaginatedNotificationsDto> { /* … */ }
@Get('unread/count')
async unreadCount(): Promise<UnreadCountDto> { /* … */ }
@Post(':id/read')
async markAsRead(@Param('id') id: string): Promise<AckDto> { /* … */ }
// …unread(), markAllAsRead(), remove()
}The DTOs are plain classes — no decorators needed, codegen reads their field types:
export class NotificationDto {
id!: string;
type!: string;
data!: Record<string, unknown>;
readAt!: string | null;
createdAt!: string;
}
export class PaginatedNotificationsDto {
items!: NotificationDto[];
page!: number;
perPage!: number;
total!: number;
}
export class UnreadCountDto { count!: number; }
export class AckDto { ok!: boolean; }
export class ListNotificationsQueryDto { page?: number; perPage?: number; }2. Configure codegen
import { defineConfig, type ValidationAdapter } from '@dudousxd/nestjs-codegen';
// forms are disabled, so this is never invoked — it only satisfies the required `validation` field.
// Swap in `zodAdapter` from @dudousxd/nestjs-codegen-zod (and enable forms) to also emit client-side
// validation schemas.
const noopAdapter: ValidationAdapter = {
name: 'noop',
importStatements: () => [],
render: () => '',
renderModule: () => ({ schemaText: '', namedNestedSchemas: new Map(), warnings: [] }),
inferType: () => 'unknown',
};
export default defineConfig({
validation: noopAdapter,
contracts: { glob: 'src/**/*.controller.ts' },
codegen: { outDir: 'src/generated' },
forms: { enabled: false }, // typed client only (routes.ts + api.ts)
});pnpm add -D @dudousxd/nestjs-codegen tsx
pnpm add @dudousxd/nestjs-client # runtime the generated api.ts importsAdd a script and run it:
{ "scripts": { "codegen": "nestjs-codegen codegen" } }pnpm codegen
# ✓ Codegen generated artifacts in src/generatedCodegen runs entirely off your source files — it does not boot your app. tsx is needed only
to load the TypeScript config file.
3. The generated client
src/generated/api.ts gives you a typed client keyed by controller and method:
import { createApi } from './generated/api';
import { createFetcher } from '@dudousxd/nestjs-client';
const api = createApi(createFetcher({ baseUrl: '/' }));
const page = await api.notificationsInbox.list({ query: { page: 1, perPage: 20 } });
// ^? PaginatedNotificationsDto
const { count } = await api.notificationsInbox.unreadCount();
await api.notificationsInbox.markAsRead({ params: { id } });Path params are required and typed ({ params: { id } }), query/body are inferred from the DTOs, and
the response type resolves to the controller method's return type. routes.ts exposes the matching
ROUTES map and a route('notificationsInbox.list') helper.
The generated response types reference the controller's return type via import('…/inbox.controller'),
so the generated client typechecks within the same project. For a separate frontend build, point
outDir into the frontend and share the DTOs (or the controller's type) across the boundary — e.g.
via a small shared *-contracts package — so the import resolves on the client side.
Pairing with the React widget
The generated createApi(fetcher) and the React package's hand-written NotificationsClient solve the
same problem two ways. Use whichever fits: the hand-written client is zero-config and dependency-free;
the generated client removes manual mirroring and follows your routes automatically. You can back the
React hooks with the generated client by adapting it behind the NotificationsClient shape, or use the
generated client directly in your own components.
Delivery tracking
Persist the real per-channel delivery status — sent, failed, delivered, bounced — not just the in-memory SendResult. The delivery-tracking package records every send and updates it from Twilio / SES status webhooks.
Channel preferences
Let users mute channels they don't want. Register PreferencesModule, mute/unmute per user (and per tenant), and muted channels are auto-skipped because the package binds the core PreferenceGate.