end to end guide
Complete walkthrough. Build a NestJS backend, generate types with Duck Gen, and consume them with Duck Query on the client.
What you will build
This guide walks you through the full Duck Gen + Duck Query workflow from scratch:
- A NestJS backend with a
usersmodule (CRUD routes + messages). - Generated types using Duck Gen.
- A type-safe client using Duck Query.
- Type-safe i18n translations using generated message types.
By the end, your client code will have zero manually written types for API calls, everything comes from the server source code.
Step 1: Set up the backend
Start with a NestJS project that has a users module.
DTO (Data Transfer Object)
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user'),
})
export type CreateUserDto = z.infer<typeof createUserSchema>
export const updateUserSchema = createUserSchema.partial()
export type UpdateUserDto = z.infer<typeof updateUserSchema>
export type UserDto = {
id: string
name: string
email: string
role: 'admin' | 'user'
createdAt: string
}
export type PaginationDto = {
page?: number
limit?: number
sort?: 'name' | 'email' | 'createdAt'
}Message constants
/**
* @duckgen messages users
*/
export const UserMessages = [
'USER_CREATED',
'USER_UPDATED',
'USER_DELETED',
'USER_NOT_FOUND',
'USER_EMAIL_TAKEN',
'USER_LIST_SUCCESS',
] as constNotice the @duckgen messages users JSDoc tag. This tells Duck Gen to scan this constant
and register it under the users group.
Response type
export type ApiResponse<T, M extends string> =
| { state: 'success'; message: M; data: T }
| { state: 'error'; message: M; data: null }Controller
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common'
import { ZodValidationPipe } from '@nestjs/zod'
import { createUserSchema, updateUserSchema } from './users.dto'
import type { CreateUserDto, UpdateUserDto, UserDto, PaginationDto } from './users.dto'
import type { ApiResponse } from '~/common/types/api.types'
import type { UserMessages } from './users.constants'
import { UsersService } from './users.service'
type UserMessage = (typeof UserMessages)[number]
@Controller('users')
export class UsersController {
constructor(private readonly service: UsersService) {}
@Get()
async findAll(
@Query() query: PaginationDto,
): Promise<ApiResponse<UserDto[], UserMessage>> {
const users = await this.service.findAll(query)
return { state: 'success', message: 'USER_LIST_SUCCESS', data: users }
}
@Get(':id')
async findOne(
@Param('id') id: string,
): Promise<ApiResponse<UserDto, UserMessage>> {
const user = await this.service.findOne(id)
if (!user) {
return { state: 'error', message: 'USER_NOT_FOUND', data: null }
}
return { state: 'success', message: 'USER_LIST_SUCCESS', data: user }
}
@Post()
async create(
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserDto,
): Promise<ApiResponse<UserDto, UserMessage>> {
const user = await this.service.create(body)
return { state: 'success', message: 'USER_CREATED', data: user }
}
@Put(':id')
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserDto,
): Promise<ApiResponse<UserDto, UserMessage>> {
const user = await this.service.update(id, body)
return { state: 'success', message: 'USER_UPDATED', data: user }
}
@Delete(':id')
async remove(
@Param('id') id: string,
): Promise<ApiResponse<null, UserMessage>> {
await this.service.remove(id)
return { state: 'success', message: 'USER_DELETED', data: null }
}
}Step 2: Configure Duck Gen
{
"$schema": "node_modules/@gentleduck/gen/duck-gen.schema.json",
"framework": "nestjs",
"extensions": {
"shared": {
"includeNodeModules": false,
"outputSource": "./generated",
"sourceGlobs": ["src/**/*.ts"],
"tsconfigPath": "./tsconfig.json"
},
"apiRoutes": {
"enabled": true,
"globalPrefix": "/api",
"normalizeAnyToUnknown": true,
"outputSource": "./generated"
},
"messages": {
"enabled": true,
"outputSource": "./generated"
}
}
}Make sure your NestJS main.ts sets the same prefix:
const app = await NestFactory.create(AppModule)
app.setGlobalPrefix('api')
await app.listen(3000)Step 3: Generate types
bunx duck-genOutput:
> Config loaded
> Processing doneDuck Gen now generates types that know about:
GET /api/users: returnsApiResponse<UserDto[], UserMessage>, acceptsPaginationDtoquery.GET /api/users/:id: returnsApiResponse<UserDto, UserMessage>, acceptsidparam.POST /api/users: returnsApiResponse<UserDto, UserMessage>, acceptsCreateUserDtobody.PUT /api/users/:id: returnsApiResponse<UserDto, UserMessage>, acceptsUpdateUserDtobody +idparam.DELETE /api/users/:id: returnsApiResponse<null, UserMessage>, acceptsidparam.- Message group
userswith keys:USER_CREATED,USER_UPDATED, etc.
Step 4: Create the Duck Query client
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
export const api = createDuckQueryClient<ApiRoutes>({
baseURL: 'http://localhost:3000',
withCredentials: true,
})
// Add auth token interceptor
api.axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})Step 5: Make type-safe API calls
import { api } from './api'
// List users with pagination
export async function getUsers(page = 1, limit = 20) {
const { data } = await api.get('/api/users', {
query: { page, limit, sort: 'name' },
})
// data: ApiResponse<UserDto[], UserMessage>
if (data.state === 'success') {
return data.data // UserDto[]
}
throw new Error(data.message)
}
// Get single user
export async function getUser(id: string) {
const { data } = await api.get('/api/users/:id', {
params: { id },
})
// data: ApiResponse<UserDto, UserMessage>
if (data.state === 'success') {
return data.data // UserDto
}
return null
}
// Create user
export async function createUser(name: string, email: string) {
const { data } = await api.post('/api/users', {
body: { name, email, role: 'user' },
// ^^^^ ^^^^^ ^^^^
// TypeScript checks these match CreateUserDto
})
return data
}
// Update user
export async function updateUser(id: string, updates: { name?: string; email?: string }) {
const { data } = await api.put('/api/users/:id', {
params: { id },
body: updates,
})
return data
}
// Delete user
export async function deleteUser(id: string) {
const { data } = await api.del('/api/users/:id', {
params: { id },
})
return data
}Every call above is fully type-checked:
- Paths are validated against generated routes.
- Body types match your DTOs.
- Response types match your controller return types.
- If you misspell a path or pass the wrong body shape, TypeScript catches it immediately.
Step 6: Add type-safe i18n
Use the generated message types to build translations:
import type { DuckgenScopedI18nByGroup, DuckGenI18nMessages } from '@gentleduck/gen/nestjs'
type Lang = 'en' | 'ar'
type I18n = DuckgenScopedI18nByGroup<Lang, DuckGenI18nMessages>
export const i18n: I18n = {
en: {
server: {
UserMessages: {
USER_CREATED: 'User created successfully',
USER_UPDATED: 'User updated successfully',
USER_DELETED: 'User deleted successfully',
USER_NOT_FOUND: 'User not found',
USER_EMAIL_TAKEN: 'Email is already in use',
USER_LIST_SUCCESS: 'Users loaded',
},
},
},
ar: {
server: {
UserMessages: {
USER_CREATED: 'تم إنشاء المستخدم بنجاح',
USER_UPDATED: 'تم تحديث المستخدم بنجاح',
USER_DELETED: 'تم حذف المستخدم بنجاح',
USER_NOT_FOUND: 'المستخدم غير موجود',
USER_EMAIL_TAKEN: 'البريد الإلكتروني مستخدم بالفعل',
USER_LIST_SUCCESS: 'تم تحميل المستخدمين',
},
},
},
}If you add a new message key on the server and regenerate, TypeScript will immediately flag every i18n file that is missing the new key.
Step 7: Use i18n in your UI
import { toast } from 'sonner'
import { i18n } from './i18n'
import type { DuckgenMessageKey } from '@gentleduck/gen/nestjs'
type Lang = 'en' | 'ar'
export function showMessage(lang: Lang, key: DuckgenMessageKey<'users'>) {
const text = i18n[lang].server.UserMessages[key]
toast.info(text)
}
export function showError(lang: Lang, key: DuckgenMessageKey<'users'>) {
const text = i18n[lang].server.UserMessages[key]
toast.error(text)
}import { api } from './api'
import { showMessage, showError } from './toast'
export async function createUserWithFeedback(
lang: 'en' | 'ar',
name: string,
email: string,
) {
try {
const { data } = await api.post('/api/users', {
body: { name, email, role: 'user' },
})
if (data.state === 'success') {
showMessage(lang, data.message) // 'USER_CREATED', type-safe
return data.data
} else {
showError(lang, data.message) // 'USER_EMAIL_TAKEN', type-safe
return null
}
} catch {
showError(lang, 'USER_NOT_FOUND')
return null
}
}Summary
Here is the complete flow:
Loading diagram...
No manual type writing. No drift between server and client. Full type safety from database to UI.
Next steps
- Templates: another complete example with auth flow.
- Duck Gen overview: deep dive into Duck Gen features.
- Duck Query overview: deep dive into the HTTP client.
- Configuration: customize Duck Gen behavior.