Skip to main content

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.

What it scans

Duck Gen looks for three things in your source files:

  1. Controller classes: any class decorated with @Controller().
  2. HTTP method decorators: methods inside controllers decorated with @Get, @Post, etc.
  3. Parameter decorators: @Body, @Query, @Param, and @Headers on method parameters.
Example: what Duck Gen reads
@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)
  }
}
Example: what Duck Gen reads
@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

DecoratorDescription
@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

DecoratorHTTP 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/ban

Parameter decorators

DecoratorMaps toRequired?Example type
@Body()bodyyes (POST/PUT/PATCH)The full DTO type
@Body('field')body.fieldno (optional field){ field?: Type }
@Query()querynoThe full query DTO
@Query('page')query.pageno (optional field){ page?: Type }
@Param('id')params.idyes{ id: Type }
@Headers()headersnoThe full headers type
@Headers('x-token')headers['x-token']no (optional field){ 'x-token'?: Type }

Path construction rules

The final route path joins three segments:

Loading diagram...

For example:

globalPrefixControllerMethodResult
/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: SigninDto

Named 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: UserDto

Complete example

A controller with several routes and the types generated from it:

The controller

src/modules/users/users.controller.ts
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)
  }
}
src/modules/users/users.controller.ts
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":

Generated: duck-gen-api-routes.d.ts (simplified)
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'
  }
}
Generated: duck-gen-api-routes.d.ts (simplified)
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

Client usage
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' },
})
Client usage
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 { ... }

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 InferReturning and 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