Skip to main content

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.

Duck Gen documentation

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 AuthSession

Duck 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

CategoryOutput
API route typesA route map with typed request shapes (body, query, params, headers) and response types for every controller method.
Message registry typesStrongly-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

Loading diagram...

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/gen
bunx @gentleduck/gen

Duck 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 checking
const res = await axios.post('/api/auth/signin', {
  emial: 'test@example.com',  // typo -- no error
  password: '123',
})
// res.data is `any` -- no type checking

After (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 typed
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 typed

The full pipeline

Loading diagram...

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 -> UserProfile
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 -> UserProfile

Getting Started

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/query

Duck Gen is framework-aware. NestJS ships first; more adapters are planned.