Skip to main content
Search...

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 your NestJS controllers and generates a TypeScript type map where each key is a route path and each value describes the HTTP method, request shape, and response type. This gives your client code compile-time knowledge of every route your server exposes.

Duck Gen is designed for multiple frameworks. This guide documents the NestJS adapter, the first adapter shipped and currently tested.

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)
  }
}

From this, Duck Gen generates a route entry for /api/users/:id (assuming globalPrefix: "/api") with the GET method, 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

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

Duck Gen builds the final route path by joining 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 is an object with up to four fields: body, query, params, and headers. Fields that don't apply to a route are set to never and cleaned up by Duck Query's CleanupNever utility.

@Body: request body

Full body: when @Body() is used without a property key, the parameter type becomes the entire body type:

@Post('signin')
signin(@Body() body: SigninDto): Promise<AuthSession> { ... }
// => body: SigninDto

Named property: when @Body('field') is used, Duck Gen 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 }

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

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

@Headers: request headers

@Get('me')
me(@Headers('authorization') auth: string): Promise<UserDto> { ... }
// => headers: { authorization?: string }

Multiple decorators of the same kind

When a method has multiple decorators of the same kind, Duck Gen merges them using intersection types:

@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

Duck Gen uses the method's return type as the response type. If your method returns Promise<T>, the response type is T:

@Get(':id')
findOne(@Param('id') id: string): Promise<UserDto> {
  return this.service.findOne(id)
}
// => response type: UserDto

Complete example

Here is a full controller with multiple routes, and what Duck Gen generates 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)
  }
}

The generated types

With globalPrefix: "/api", Duck Gen produces:

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' },
})

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 { ... }

Methods without HTTP decorators are ignored

Only methods with recognized HTTP decorators (@Get, @Post, etc.) are included. Private methods, lifecycle hooks, and helper methods are not scanned.

Complex return types

Duck Gen handles complex return types including:

  • 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 recursively resolves these into their concrete shapes.

Multiple controllers for the same module

Duck Gen scans all controllers in your project. If you have an admin controller and a public controller for the same resource, both sets of routes appear in the generated map:

@Controller('users')
export class UsersController { ... }
 
@Controller('admin/users')
export class AdminUsersController { ... }
// Both generate routes: /api/users/... and /api/admin/users/...

Next steps