Skip to main content

type safe apis with duck gen

How Duck Gen eliminates the gap between your server routes and client code. A deep dive into the compiler extension workflow.

The Type Gap

Server and client end up speaking different type languages.

You define a DTO on the server, write a matching type on the client, and someone renames a field. The client keeps compiling. The bug ships.

Duck Gen closes the gap.


The Workflow

Loading diagram...

Step 1: Write your server code normally

// src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  @Post('signin')
  signin(@Body() body: SigninDto): Promise<AuthSession> {
    return this.authService.signin(body)
  }
 
  @Post('signup')
  signup(@Body() body: SignupDto): Promise<AuthSession> {
    return this.authService.signup(body)
  }
}
// src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  @Post('signin')
  signin(@Body() body: SigninDto): Promise<AuthSession> {
    return this.authService.signin(body)
  }
 
  @Post('signup')
  signup(@Body() body: SignupDto): Promise<AuthSession> {
    return this.authService.signup(body)
  }
}

Step 2: Generate types

bunx @gentleduck/gen
bunx @gentleduck/gen

Duck Gen parses your sources with ts-morph, finds every @Controller, and extracts:

  • HTTP method decorators (@Get, @Post, @Put, @Delete, @Patch)
  • Route paths (controller prefix + method path)
  • Parameter decorators (@Body, @Query, @Param, @Headers)
  • Return types

Step 3: Use typed routes on the client

import { createDuckQuery } from '@gentleduck/query'
import type { ApiRoutes } from './generated/api-routes'
 
const client = createDuckQuery<ApiRoutes>({
  baseURL: 'https://api.example.com',
})
 
// Fully typed — body shape, response type, route path
const session = await client.post('/api/auth/signin', {
  body: { email: 'user@example.com', password: 'secret' },
})
// session.data is AuthSession
import { createDuckQuery } from '@gentleduck/query'
import type { ApiRoutes } from './generated/api-routes'
 
const client = createDuckQuery<ApiRoutes>({
  baseURL: 'https://api.example.com',
})
 
// Fully typed — body shape, response type, route path
const session = await client.post('/api/auth/signin', {
  body: { email: 'user@example.com', password: 'secret' },
})
// session.data is AuthSession

What Gets Generated

The output is a single .d.ts file with a complete route map:

// generated/api-routes.d.ts — do not edit
export interface ApiRoutes {
  '/api/auth/signin': {
    POST: { body: SigninDto; res: AuthSession }
  }
  '/api/auth/signup': {
    POST: { body: SignupDto; res: AuthSession }
  }
  '/api/users/:id': {
    GET: { params: { id: string }; res: UserProfile }
    PATCH: { params: { id: string }; body: UpdateUserDto; res: UserProfile }
  }
}
// generated/api-routes.d.ts — do not edit
export interface ApiRoutes {
  '/api/auth/signin': {
    POST: { body: SigninDto; res: AuthSession }
  }
  '/api/auth/signup': {
    POST: { body: SignupDto; res: AuthSession }
  }
  '/api/users/:id': {
    GET: { params: { id: string }; res: UserProfile }
    PATCH: { params: { id: string }; body: UpdateUserDto; res: UserProfile }
  }
}

A pure type file — no runtime code, no bundle impact. It disappears at compile time.


Configuration

{
  "framework": "nestjs",
  "srcDir": "./src",
  "outputDir": "./generated",
  "apiPrefix": "/api",
  "include": ["**/*.controller.ts"],
  "exclude": ["**/*.spec.ts"]
}
{
  "framework": "nestjs",
  "srcDir": "./src",
  "outputDir": "./generated",
  "apiPrefix": "/api",
  "include": ["**/*.controller.ts"],
  "exclude": ["**/*.spec.ts"]
}

Save this as duck-gen.json in your project root. Duck Gen reads it automatically.


Why Not tRPC / GraphQL Codegen?

Duck GentRPCGraphQL Codegen
Server frameworkNestJS (REST)Custom routerGraphQL servers
Client couplingNone — types onlyTight couplingSchema-first
Runtime costZero (.d.ts only)MinimalVaries
Migration effortDrop-in — no server changesFull rewriteSchema migration
Existing REST APIsWorks immediatelyNot applicableNot applicable

Duck Gen is for teams with existing REST APIs that want type safety without a server rewrite.


Getting Started

bun add -d @gentleduck/gen    # Dev dependency — runs at build time
bun add @gentleduck/query     # Runtime — typed HTTP client
bun add -d @gentleduck/gen    # Dev dependency — runs at build time
bun add @gentleduck/query     # Runtime — typed HTTP client