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
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/genbunx @gentleduck/genDuck 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 AuthSessionimport { 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 AuthSessionWhat 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 Gen | tRPC | GraphQL Codegen | |
|---|---|---|---|
| Server framework | NestJS (REST) | Custom router | GraphQL servers |
| Client coupling | None — types only | Tight coupling | Schema-first |
| Runtime cost | Zero (.d.ts only) | Minimal | Varies |
| Migration effort | Drop-in — no server changes | Full rewrite | Schema migration |
| Existing REST APIs | Works immediately | Not applicable | Not applicable |
Duck Gen is for teams with existing REST APIs that want type safety without a server rewrite.
Duck Gen reads your existing NestJS controllers. No new decorators, no architectural changes, no new router. The server code stays the same.
Getting Started
bun add -d @gentleduck/gen # Dev dependency — runs at build time
bun add @gentleduck/query # Runtime — typed HTTP clientbun add -d @gentleduck/gen # Dev dependency — runs at build time
bun add @gentleduck/query # Runtime — typed HTTP client