duck gen and duck query
Type-safe API generation and HTTP client for TypeScript. Scan your server code, emit .d.ts files, and get fully typed requests and responses with zero runtime cost.
The Problem
Full-stack TypeScript teams hit the same wall: keeping types in sync.
You add a route on the server, update the DTO, then write matching types on the client by hand. Eventually someone renames a field and the client silently breaks.
// Server: you add a new route
@Post('signup')
signup(@Body() body: SignupDto): Promise<AuthSession> { ... }
// Client: you manually write the matching types... or forget to
type SignupReq = { email: string; password: string } // hope this matches SignupDto
type SignupRes = { token: string } // hope this matches AuthSession// Server: you add a new route
@Post('signup')
signup(@Body() body: SignupDto): Promise<AuthSession> { ... }
// Client: you manually write the matching types... or forget to
type SignupReq = { email: string; password: string } // hope this matches SignupDto
type SignupRes = { token: string } // hope this matches AuthSessionDuck Gen and Duck Query close this gap.
Duck Gen -- The Compiler Extension
Duck Gen reads your server source and emits TypeScript definition files (.d.ts) for every API route and message key it finds.
The contract layer stops being something you write by hand.
What it generates
| Category | Output |
|---|---|
| API route types | A route map with typed request shapes (body, query, params, headers) and response types for every controller method. |
| Message registry types | Strongly-typed i18n dictionaries derived from @duckgen message tags in your code. |
Both outputs are .d.ts files you import directly -- no runtime cost, just types.
How it works
Duck Gen parses your source statically with ts-morph. It finds every @Controller class, extracts the HTTP method decorators (@Get, @Post, @Put, @Delete, @Patch), resolves the full route path, and reads parameter decorators (@Body, @Query, @Param, @Headers) for request shapes. Response types come from the method's return type.
The result is a route map:
// Generated by duck-gen -- 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 by duck-gen -- 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
}
}
}Configuration
Duck Gen reads a duck-gen.json file in your project root:
{
"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"]
}One command generates everything:
bunx @gentleduck/genbunx @gentleduck/genDuck Query -- The Type-Safe Client
Duck Query is an HTTP client built on Axios. It consumes the route map from Duck Gen so request paths, bodies, query parameters, and responses are checked at compile time.
Before and after
Before (manual types, no safety):
const res = await axios.post('/api/auth/signin', {
emial: 'test@example.com', // typo -- no error
password: '123',
})
// res.data is `any` -- no type checkingconst res = await axios.post('/api/auth/signin', {
emial: 'test@example.com', // typo -- no error
password: '123',
})
// res.data is `any` -- no type checkingAfter (Duck Query + Duck Gen):
const res = await client.post('/api/auth/signin', {
emial: 'test@example.com', // TypeScript error: 'emial' does not exist on SigninDto
password: '123',
})
// res.data is AuthSession -- fully typedconst res = await client.post('/api/auth/signin', {
emial: 'test@example.com', // TypeScript error: 'emial' does not exist on SigninDto
password: '123',
})
// res.data is AuthSession -- fully typedThe full pipeline
Setup
import { createDuckQuery } from '@gentleduck/query'
import type { ApiRoutes } from './generated/api-routes'
const client = createDuckQuery<ApiRoutes>({
baseURL: 'https://api.example.com',
})
// Every method is fully typed
const session = await client.post('/api/auth/signin', {
body: { email: 'user@example.com', password: 'secret' },
})
// session.data -> AuthSession
const user = await client.get('/api/users/:id', {
params: { id: '123' },
})
// user.data -> UserProfileimport { createDuckQuery } from '@gentleduck/query'
import type { ApiRoutes } from './generated/api-routes'
const client = createDuckQuery<ApiRoutes>({
baseURL: 'https://api.example.com',
})
// Every method is fully typed
const session = await client.post('/api/auth/signin', {
body: { email: 'user@example.com', password: 'secret' },
})
// session.data -> AuthSession
const user = await client.get('/api/users/:id', {
params: { id: '123' },
})
// user.data -> UserProfileGetting Started
Duck Gen Docs
Configuration, generated types, API routes, and message registries.
Duck Query Docs
Client setup, typed methods, advanced usage, and custom route maps.
Install
# Install Duck Gen (dev dependency -- only runs at build time)
bun add -d @gentleduck/gen
# Install Duck Query (runtime dependency)
bun add @gentleduck/query# Install Duck Gen (dev dependency -- only runs at build time)
bun add -d @gentleduck/gen
# Install Duck Query (runtime dependency)
bun add @gentleduck/queryDuck Gen is framework-aware. NestJS ships first; more adapters are planned.
Duck Gen emits .d.ts files only -- types that disappear at compile time. Duck Query wraps Axios and adds type inference without bundle weight.