api routes
Learn how Duck Gen scans NestJS controllers and generates fully typed route maps with request and response types.
Overview
The API routes extension scans NestJS controllers and produces a TypeScript type map. Each key is a route path. Each value lists the HTTP method, request shape, and response type. Clients get compile-time knowledge of every route on the server.
Duck Gen targets multiple frameworks. This page documents the NestJS adapter, which is the first adapter shipped.
After duck-gen runs, the client knows every route path, what to send, and what comes
back. Misspelled route, wrong body shape, or missing param all fail at compile time.
What it scans
Duck Gen looks for three things in your source files:
- Controller classes: any class decorated with
@Controller(). - HTTP method decorators: methods inside controllers decorated with
@Get,@Post, etc. - Parameter decorators:
@Body,@Query,@Param, and@Headerson method parameters.
@Controller('users') // controller path
export class UsersController {
@Get(':id') // HTTP method + method path
findOne(
@Param('id') id: string, // parameter decorator
@Query() query: FindUserQuery,
): Promise<UserDto> { // return type becomes response type
return this.usersService.findOne(id, query)
}
}@Controller('users') // controller path
export class UsersController {
@Get(':id') // HTTP method + method path
findOne(
@Param('id') id: string, // parameter decorator
@Query() query: FindUserQuery,
): Promise<UserDto> { // return type becomes response type
return this.usersService.findOne(id, query)
}
}With globalPrefix: "/api", this becomes a GET route at /api/users/:id with
params: { id: string }, query: FindUserQuery, and response type UserDto.
Supported decorators
Controller decorator
| Decorator | Description |
|---|---|
@Controller() | No path prefix, routes start from globalPrefix. |
@Controller('users') | Adds /users prefix to all routes in this controller. |
@Controller('admin/users') | Nested paths work too. |
HTTP method decorators
| Decorator | HTTP Method |
|---|---|
@Get() | GET |
@Post() | POST |
@Put() | PUT |
@Patch() | PATCH |
@Delete() | DELETE |
@Options() | OPTIONS |
@Head() | HEAD |
@All() | ALL |
Each decorator accepts an optional string path:
@Get() // matches the controller path exactly
@Get('list') // appends /list to the controller path
@Get(':id') // appends /:id (path parameter)
@Post(':id/ban') // appends /:id/ban@Get() // matches the controller path exactly
@Get('list') // appends /list to the controller path
@Get(':id') // appends /:id (path parameter)
@Post(':id/ban') // appends /:id/banParameter decorators
| Decorator | Maps to | Required? | Example type |
|---|---|---|---|
@Body() | body | yes (POST/PUT/PATCH) | The full DTO type |
@Body('field') | body.field | no (optional field) | { field?: Type } |
@Query() | query | no | The full query DTO |
@Query('page') | query.page | no (optional field) | { page?: Type } |
@Param('id') | params.id | yes | { id: Type } |
@Headers() | headers | no | The full headers type |
@Headers('x-token') | headers['x-token'] | no (optional field) | { 'x-token'?: Type } |
@Body() with no argument uses the whole parameter type as the body. @Body('email')
produces { email?: Type } — an object with that one optional field. @Query and
@Headers follow the same rule.
Path construction rules
The final route path joins three segments:
For example:
| globalPrefix | Controller | Method | Result |
|---|---|---|---|
/api | @Controller('users') | @Get(':id') | /api/users/:id |
/api | @Controller('admin/users') | @Post() | /api/admin/users |
| (none) | @Controller('auth') | @Post('signin') | /auth/signin |
/api/v2 | @Controller() | @Get('health') | /api/v2/health |
Rules:
- Empty segments are removed.
- Slashes are normalized (no double slashes).
- Only string literal paths are supported. If a decorator uses a variable or expression, that route is skipped.
Request shape rules
Each route's request type has up to four fields: body, query, params, and headers.
Fields that don't apply are set to never and stripped by Duck Query's CleanupNever
utility.
@Body: request body
Full body: @Body() with no property key makes the parameter type the entire body:
@Post('signin')
signin(@Body() body: SigninDto): Promise<AuthSession> { ... }
// => body: SigninDto@Post('signin')
signin(@Body() body: SigninDto): Promise<AuthSession> { ... }
// => body: SigninDtoNamed property: @Body('field') creates an object with that field as optional:
@Post('update')
update(@Body('email') email: string, @Body('name') name: string): Promise<UserDto> { ... }
// => body: { email?: string } & { name?: string }@Post('update')
update(@Body('email') email: string, @Body('name') name: string): Promise<UserDto> { ... }
// => body: { email?: string } & { name?: string }@Query: query string
Works the same as @Body:
@Get('search')
search(@Query() query: SearchDto): Promise<SearchResult[]> { ... }
// => query: SearchDto
@Get('list')
list(@Query('page') page: number, @Query('limit') limit: number): Promise<UserDto[]> { ... }
// => query: { page?: number } & { limit?: number }@Get('search')
search(@Query() query: SearchDto): Promise<SearchResult[]> { ... }
// => query: SearchDto
@Get('list')
list(@Query('page') page: number, @Query('limit') limit: number): Promise<UserDto[]> { ... }
// => query: { page?: number } & { limit?: number }@Param: path parameters
Path params are always required (not optional):
@Get(':id')
findOne(@Param('id') id: string): Promise<UserDto> { ... }
// => params: { id: string }
@Get(':userId/posts/:postId')
findPost(
@Param('userId') userId: string,
@Param('postId') postId: string,
): Promise<PostDto> { ... }
// => params: { userId: string } & { postId: string }@Get(':id')
findOne(@Param('id') id: string): Promise<UserDto> { ... }
// => params: { id: string }
@Get(':userId/posts/:postId')
findPost(
@Param('userId') userId: string,
@Param('postId') postId: string,
): Promise<PostDto> { ... }
// => params: { userId: string } & { postId: string }@Headers: request headers
@Get('me')
me(@Headers('authorization') auth: string): Promise<UserDto> { ... }
// => headers: { authorization?: string }@Get('me')
me(@Headers('authorization') auth: string): Promise<UserDto> { ... }
// => headers: { authorization?: string }Multiple decorators of the same kind
Multiple decorators of the same kind merge into an intersection type:
@Post(':id/transfer')
transfer(
@Param('id') id: string,
@Body('amount') amount: number,
@Body('to') to: string,
@Query('confirm') confirm: boolean,
): Promise<TransferResult> { ... }
// => params: { id: string }
// => body: { amount?: number } & { to?: string }
// => query: { confirm?: boolean }@Post(':id/transfer')
transfer(
@Param('id') id: string,
@Body('amount') amount: number,
@Body('to') to: string,
@Query('confirm') confirm: boolean,
): Promise<TransferResult> { ... }
// => params: { id: string }
// => body: { amount?: number } & { to?: string }
// => query: { confirm?: boolean }Response types
The method's return type is the response type. Promise<T> unwraps to T:
@Get(':id')
findOne(@Param('id') id: string): Promise<UserDto> {
return this.service.findOne(id)
}
// => response type: UserDto@Get(':id')
findOne(@Param('id') id: string): Promise<UserDto> {
return this.service.findOne(id)
}
// => response type: UserDtoAdd explicit return type annotations on controller methods. If the return type is inferred
as any, Duck Gen prints a warning and converts it to unknown (with
normalizeAnyToUnknown enabled). Explicit types produce the best output.
Complete example
A controller with several routes and the types generated from it:
The controller
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common'
@Controller('users')
export class UsersController {
constructor(private readonly service: UsersService) {}
@Get()
findAll(@Query() query: PaginationDto): Promise<UserDto[]> {
return this.service.findAll(query)
}
@Get(':id')
findOne(@Param('id') id: string): Promise<UserDto> {
return this.service.findOne(id)
}
@Post()
create(@Body() body: CreateUserDto): Promise<UserDto> {
return this.service.create(body)
}
@Put(':id')
update(
@Param('id') id: string,
@Body() body: UpdateUserDto,
): Promise<UserDto> {
return this.service.update(id, body)
}
@Delete(':id')
remove(@Param('id') id: string): Promise<void> {
return this.service.remove(id)
}
}import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common'
@Controller('users')
export class UsersController {
constructor(private readonly service: UsersService) {}
@Get()
findAll(@Query() query: PaginationDto): Promise<UserDto[]> {
return this.service.findAll(query)
}
@Get(':id')
findOne(@Param('id') id: string): Promise<UserDto> {
return this.service.findOne(id)
}
@Post()
create(@Body() body: CreateUserDto): Promise<UserDto> {
return this.service.create(body)
}
@Put(':id')
update(
@Param('id') id: string,
@Body() body: UpdateUserDto,
): Promise<UserDto> {
return this.service.update(id, body)
}
@Delete(':id')
remove(@Param('id') id: string): Promise<void> {
return this.service.remove(id)
}
}The generated types
With globalPrefix: "/api":
import type { PaginationDto } from '../../src/modules/users/users.dto'
import type { CreateUserDto } from '../../src/modules/users/users.dto'
import type { UpdateUserDto } from '../../src/modules/users/users.dto'
import type { UserDto } from '../../src/modules/users/users.dto'
export interface ApiRoutes {
'/api/users': {
body: never
query: PaginationDto
params: never
headers: never
res: UserDto[]
method: 'GET'
}
'/api/users/:id': {
body: never
query: never
params: { id: string }
headers: never
res: UserDto
method: 'GET'
} | {
body: UpdateUserDto
query: never
params: { id: string }
headers: never
res: UserDto
method: 'PUT'
} | {
body: never
query: never
params: { id: string }
headers: never
res: void
method: 'DELETE'
}
'/api/users': {
body: CreateUserDto
query: never
params: never
headers: never
res: UserDto
method: 'POST'
}
}import type { PaginationDto } from '../../src/modules/users/users.dto'
import type { CreateUserDto } from '../../src/modules/users/users.dto'
import type { UpdateUserDto } from '../../src/modules/users/users.dto'
import type { UserDto } from '../../src/modules/users/users.dto'
export interface ApiRoutes {
'/api/users': {
body: never
query: PaginationDto
params: never
headers: never
res: UserDto[]
method: 'GET'
}
'/api/users/:id': {
body: never
query: never
params: { id: string }
headers: never
res: UserDto
method: 'GET'
} | {
body: UpdateUserDto
query: never
params: { id: string }
headers: never
res: UserDto
method: 'PUT'
} | {
body: never
query: never
params: { id: string }
headers: never
res: void
method: 'DELETE'
}
'/api/users': {
body: CreateUserDto
query: never
params: never
headers: never
res: UserDto
method: 'POST'
}
}Using the types on the client
import type { RouteReq, RouteRes } from '@gentleduck/gen/nestjs'
// Extract types for a specific route
type CreateUserReq = RouteReq<'/api/users'>
// => { body: CreateUserDto }
type UserResponse = RouteRes<'/api/users/:id'>
// => UserDto
// Use with Duck Query for fully typed HTTP calls
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
const client = createDuckQueryClient<ApiRoutes>({
baseURL: 'http://localhost:3000',
})
// TypeScript enforces the correct request shape
const { data: users } = await client.get('/api/users', {
query: { page: 1, limit: 10 },
})
const { data: user } = await client.get('/api/users/:id', {
params: { id: 'u_123' },
})
const { data: created } = await client.post('/api/users', {
body: { email: 'duck@example.com', name: 'Duck' },
})import type { RouteReq, RouteRes } from '@gentleduck/gen/nestjs'
// Extract types for a specific route
type CreateUserReq = RouteReq<'/api/users'>
// => { body: CreateUserDto }
type UserResponse = RouteRes<'/api/users/:id'>
// => UserDto
// Use with Duck Query for fully typed HTTP calls
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
const client = createDuckQueryClient<ApiRoutes>({
baseURL: 'http://localhost:3000',
})
// TypeScript enforces the correct request shape
const { data: users } = await client.get('/api/users', {
query: { page: 1, limit: 10 },
})
const { data: user } = await client.get('/api/users/:id', {
params: { id: 'u_123' },
})
const { data: created } = await client.post('/api/users', {
body: { email: 'duck@example.com', name: 'Duck' },
})Edge cases and notes
Dynamic paths are skipped
Duck Gen only supports string literal paths. This will be skipped:
const ROUTE = 'users'
@Controller(ROUTE) // skipped, not a string literal
export class UsersController { ... }const ROUTE = 'users'
@Controller(ROUTE) // skipped, not a string literal
export class UsersController { ... }Duck Gen runs at build time, not runtime. It reads the AST to find decorator arguments. Variables and expressions need runtime evaluation, which static analysis can't do. Keep decorator paths as string literals.
Methods without HTTP decorators are ignored
Only methods with @Get, @Post, and the other HTTP decorators are scanned. Private
methods, lifecycle hooks, and helpers are skipped.
Complex return types
Duck Gen resolves:
- Generic types like
Promise<T> - Union types like
UserDto | null - Drizzle ORM's
InferReturningand similar utility types - Intersection types
- Array types
The type expander walks these recursively into concrete shapes.
Multiple controllers for the same module
All controllers in the project are scanned. An admin controller and a public controller for the same resource produce two sets of routes:
@Controller('users')
export class UsersController { ... }
@Controller('admin/users')
export class AdminUsersController { ... }
// Both generate routes: /api/users/... and /api/admin/users/...@Controller('users')
export class UsersController { ... }
@Controller('admin/users')
export class AdminUsersController { ... }
// Both generate routes: /api/users/... and /api/admin/users/...Next steps
- Configuration reference: customize scanning and output behavior.
- Generated types reference: learn every exported type.
- Messages guide: generate typed i18n dictionaries.
- Duck Query: use generated types with a type-safe HTTP client.