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

Every full-stack TypeScript team eventually hits the same problem: the server and client speak different type languages.

You define a DTO on the server. You write a matching type on the client. Someone renames a field. The client keeps compiling. The bug ships to production.

Duck Gen exists to close this gap permanently.


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 reads your source files with ts-morph, finds every @Controller class, 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 }
  }
}

This is a pure type file — no runtime code, no bundle impact. It disappears completely 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 designed for teams that already have REST APIs and want type safety without rewriting their server.


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