Skip to main content

templates

Full Duck Gen + Duck Query + NestJS example with DTOs, constants, and a controller.

A minimal end-to-end example: Duck Gen, NestJS, and Duck Query together. It shows the backend contract flowing into a type-safe client, with a DTO file, a messages constants file, a controller, and enough wiring to feel real.

Client-to-server flow

  1. Define messages and DTOs in the backend.
  2. Tag messages with @duckgen for scanning.
  3. Run Duck Gen to produce ApiRoutes and i18n types.
  4. Call routes with Duck Query on the client.
  5. Keep server messages and client translations in sync through the generated i18n types.

File layout

.
├── duck-gen.json
├── backend
│   ├── src
│   │   ├── common
│   │   │   ├── constants.ts
│   │   │   └── types
│   │   │       └── api.types.ts
│   │   └── modules
│   │       └── auth
│   │           ├── auth.constants.ts
│   │           ├── auth.controller.ts
│   │           ├── auth.dto.ts
│   │           ├── auth.filters.ts
│   │           ├── auth.module.ts
│   │           ├── auth.service.ts
│   │           ├── auth.types.ts
│   │           └── index.ts
└── client
    └── auth.query.ts
.
├── duck-gen.json
├── backend
│   ├── src
│   │   ├── common
│   │   │   ├── constants.ts
│   │   │   └── types
│   │   │       └── api.types.ts
│   │   └── modules
│   │       └── auth
│   │           ├── auth.constants.ts
│   │           ├── auth.controller.ts
│   │           ├── auth.dto.ts
│   │           ├── auth.filters.ts
│   │           ├── auth.module.ts
│   │           ├── auth.service.ts
│   │           ├── auth.types.ts
│   │           └── index.ts
└── client
    └── auth.query.ts

duck-gen.json

duck-gen.json
{
  "$schema": "node_modules/@gentleduck/gen/duck-gen.schema.json",
  "framework": "nestjs",
  "extensions": {
    "shared": {
      "includeNodeModules": false,
      "outputSource": "./generated",
      "sourceGlobs": ["src/**/*.ts"],
      "tsconfigPath": "./tsconfig.json"
    },
    "apiRoutes": {
      "enabled": true,
      "globalPrefix": "/api",
      "normalizeAnyToUnknown": true,
      "outputSource": "./generated"
    },
    "messages": {
      "enabled": true,
      "outputSource": "./generated"
    }
  }
}
duck-gen.json
{
  "$schema": "node_modules/@gentleduck/gen/duck-gen.schema.json",
  "framework": "nestjs",
  "extensions": {
    "shared": {
      "includeNodeModules": false,
      "outputSource": "./generated",
      "sourceGlobs": ["src/**/*.ts"],
      "tsconfigPath": "./tsconfig.json"
    },
    "apiRoutes": {
      "enabled": true,
      "globalPrefix": "/api",
      "normalizeAnyToUnknown": true,
      "outputSource": "./generated"
    },
    "messages": {
      "enabled": true,
      "outputSource": "./generated"
    }
  }
}

Duck Gen reads NestJS controllers and DTOs (via duck-gen.json) and emits ApiRoutes. Duck Query uses ApiRoutes to type calls like /api/auth/signin. Keep controller paths and globalPrefix aligned with client routes so the contract stays correct.

Server-side (NestJS)

src/common/constants.ts
export const ZodMessages = {
  ZOD_EXPECTED_STRING: 'ZOD_EXPECTED_STRING',
  ZOD_TOO_LONG: 'ZOD_TOO_LONG',
  ZOD_TOO_SHORT: 'ZOD_TOO_SHORT',
} as const
src/common/constants.ts
export const ZodMessages = {
  ZOD_EXPECTED_STRING: 'ZOD_EXPECTED_STRING',
  ZOD_TOO_LONG: 'ZOD_TOO_LONG',
  ZOD_TOO_SHORT: 'ZOD_TOO_SHORT',
} as const
src/common/types/api.types.ts
export type Response<M extends string, T> =
  | {
      state: 'success'
      message: M
      data: T
    }
  | {
      state: 'error'
      message: M
      data: null
    }
 
export type ResponseType<TFn extends (...args: any[]) => any, M extends string> =
  Awaited<ReturnType<TFn>> extends infer TData ? Response<M, TData> : never
src/common/types/api.types.ts
export type Response<M extends string, T> =
  | {
      state: 'success'
      message: M
      data: T
    }
  | {
      state: 'error'
      message: M
      data: null
    }
 
export type ResponseType<TFn extends (...args: any[]) => any, M extends string> =
  Awaited<ReturnType<TFn>> extends infer TData ? Response<M, TData> : never
src/modules/auth/auth.constants.ts
import { ZodMessages } from '~/common/constants'
 
/**
 * @duckgen messages
 */
export const AuthMessages = [
  'AUTH_SIGNIN_SUCCESS',
  'AUTH_USERNAME_INVALID',
  'AUTH_PASSWORD_INVALID',
  'AUTH_SIGNIN_FAILED',
 
  // include shared zod messages
  ...Object.values(ZodMessages),
] as const
src/modules/auth/auth.constants.ts
import { ZodMessages } from '~/common/constants'
 
/**
 * @duckgen messages
 */
export const AuthMessages = [
  'AUTH_SIGNIN_SUCCESS',
  'AUTH_USERNAME_INVALID',
  'AUTH_PASSWORD_INVALID',
  'AUTH_SIGNIN_FAILED',
 
  // include shared zod messages
  ...Object.values(ZodMessages),
] as const
src/modules/auth/auth.types.ts
import { AuthMessages } from './auth.constants'
 
export type AuthMessage = (typeof AuthMessages)[number]
src/modules/auth/auth.types.ts
import { AuthMessages } from './auth.constants'
 
export type AuthMessage = (typeof AuthMessages)[number]
src/modules/auth/auth.dto.ts
import { z } from 'zod'
import type { AuthMessage } from './auth.types'
 
const errorMessage = <T extends AuthMessage>(message: T) => ({ message })
 
const string = z
  .string({ ...errorMessage('ZOD_EXPECTED_STRING') })
  .max(30, { ...errorMessage('ZOD_TOO_LONG') })
 
export const signinSchema = z.object({
  password: string.min(8, { ...errorMessage('ZOD_TOO_SHORT') }),
  username: string.min(3, { ...errorMessage('ZOD_TOO_SHORT') }),
})
 
export type SigninDto = z.infer<typeof signinSchema>
src/modules/auth/auth.dto.ts
import { z } from 'zod'
import type { AuthMessage } from './auth.types'
 
const errorMessage = <T extends AuthMessage>(message: T) => ({ message })
 
const string = z
  .string({ ...errorMessage('ZOD_EXPECTED_STRING') })
  .max(30, { ...errorMessage('ZOD_TOO_LONG') })
 
export const signinSchema = z.object({
  password: string.min(8, { ...errorMessage('ZOD_TOO_SHORT') }),
  username: string.min(3, { ...errorMessage('ZOD_TOO_SHORT') }),
})
 
export type SigninDto = z.infer<typeof signinSchema>
src/modules/auth/auth.utils.ts
import { HttpException, HttpStatus } from '@nestjs/common'
 
export function throwError<M extends string>(message: M, statusCode: number): never {
  throw new HttpException({ message }, statusCode as HttpStatus)
}
 
export class PasswordHasher {
  static async comparePassword(_plain: string, _hash: string) {
    // Replace with your real hashing, bcrypt/argon2, etc.
    return _plain === '123456' && _hash === 'hashed'
  }
}
src/modules/auth/auth.utils.ts
import { HttpException, HttpStatus } from '@nestjs/common'
 
export function throwError<M extends string>(message: M, statusCode: number): never {
  throw new HttpException({ message }, statusCode as HttpStatus)
}
 
export class PasswordHasher {
  static async comparePassword(_plain: string, _hash: string) {
    // Replace with your real hashing, bcrypt/argon2, etc.
    return _plain === '123456' && _hash === 'hashed'
  }
}
src/modules/auth/auth.filters.ts
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'
import { Catch, HttpException } from '@nestjs/common'
 
@Catch(HttpException)
export class ErrorExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const res = ctx.getResponse()
 
    const status = exception.getStatus()
    const body = exception.getResponse() as any
 
    // Normalize to a simple shape for the client
    res.status(status).json({
      state: 'error',
      message: body?.message ?? 'UNKNOWN_ERROR',
      data: null,
    })
  }
}
src/modules/auth/auth.filters.ts
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'
import { Catch, HttpException } from '@nestjs/common'
 
@Catch(HttpException)
export class ErrorExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const res = ctx.getResponse()
 
    const status = exception.getStatus()
    const body = exception.getResponse() as any
 
    // Normalize to a simple shape for the client
    res.status(status).json({
      state: 'error',
      message: body?.message ?? 'UNKNOWN_ERROR',
      data: null,
    })
  }
}
src/modules/auth/auth.service.ts
import { Injectable } from '@nestjs/common'
import type { AuthMessage } from './auth.types'
import type { SigninDto } from './auth.dto'
import { PasswordHasher, throwError } from './auth.utils'
 
type User = { id: string; username: string }
 
@Injectable()
export class AuthService {
  // Replace with your DB implementation
  private fakeUser = { id: '1', username: 'duck', password_hash: 'hashed' }
 
  async signin(data: SigninDto): Promise<User> {
    try {
      const user = data.username === this.fakeUser.username ? this.fakeUser : null
 
      if (!user) {
        throwError<AuthMessage>('AUTH_USERNAME_INVALID', 401)
      }
 
      const passwordMatch = await PasswordHasher.comparePassword(data.password, user.password_hash)
      if (!passwordMatch) {
        throwError<AuthMessage>('AUTH_PASSWORD_INVALID', 401)
      }
 
      return { id: user.id, username: user.username }
    } catch {
      // if it was already a typed error filter will handle it
      throwError<AuthMessage>('AUTH_SIGNIN_FAILED', 500)
    }
  }
}
src/modules/auth/auth.service.ts
import { Injectable } from '@nestjs/common'
import type { AuthMessage } from './auth.types'
import type { SigninDto } from './auth.dto'
import { PasswordHasher, throwError } from './auth.utils'
 
type User = { id: string; username: string }
 
@Injectable()
export class AuthService {
  // Replace with your DB implementation
  private fakeUser = { id: '1', username: 'duck', password_hash: 'hashed' }
 
  async signin(data: SigninDto): Promise<User> {
    try {
      const user = data.username === this.fakeUser.username ? this.fakeUser : null
 
      if (!user) {
        throwError<AuthMessage>('AUTH_USERNAME_INVALID', 401)
      }
 
      const passwordMatch = await PasswordHasher.comparePassword(data.password, user.password_hash)
      if (!passwordMatch) {
        throwError<AuthMessage>('AUTH_PASSWORD_INVALID', 401)
      }
 
      return { id: user.id, username: user.username }
    } catch {
      // if it was already a typed error filter will handle it
      throwError<AuthMessage>('AUTH_SIGNIN_FAILED', 500)
    }
  }
}
src/modules/auth/auth.controller.ts
import { Body, Controller, Post, Session, UseFilters } from '@nestjs/common'
import { ZodValidationPipe } from '@nestjs/zod'
import { signinSchema, type SigninDto } from './auth.dto'
import { AuthService } from './auth.service'
import { ErrorExceptionFilter } from './auth.filters'
import type { AuthMessage } from './auth.types'
import type { ResponseType } from '~/common/types/api.types'
 
type SessionData = { user?: unknown }
 
class EmailService {
  // placeholder to show realistic dependency wiring
}
 
@Controller('auth')
@UseFilters(ErrorExceptionFilter)
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly emailService: EmailService,
  ) {}
 
  @Post('signin')
  async signin(
    @Body(new ZodValidationPipe(signinSchema)) body: SigninDto,
    @Session() session: SessionData,
  ): Promise<ResponseType<typeof this.authService.signin, AuthMessage>> {
    const data = await this.authService.signin(body)
    session.user = data as never
 
    return {
      data,
      message: 'AUTH_SIGNIN_SUCCESS',
      state: 'success',
    }
  }
}
src/modules/auth/auth.controller.ts
import { Body, Controller, Post, Session, UseFilters } from '@nestjs/common'
import { ZodValidationPipe } from '@nestjs/zod'
import { signinSchema, type SigninDto } from './auth.dto'
import { AuthService } from './auth.service'
import { ErrorExceptionFilter } from './auth.filters'
import type { AuthMessage } from './auth.types'
import type { ResponseType } from '~/common/types/api.types'
 
type SessionData = { user?: unknown }
 
class EmailService {
  // placeholder to show realistic dependency wiring
}
 
@Controller('auth')
@UseFilters(ErrorExceptionFilter)
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly emailService: EmailService,
  ) {}
 
  @Post('signin')
  async signin(
    @Body(new ZodValidationPipe(signinSchema)) body: SigninDto,
    @Session() session: SessionData,
  ): Promise<ResponseType<typeof this.authService.signin, AuthMessage>> {
    const data = await this.authService.signin(body)
    session.user = data as never
 
    return {
      data,
      message: 'AUTH_SIGNIN_SUCCESS',
      state: 'success',
    }
  }
}
src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
 
class EmailService {}
 
@Module({
  controllers: [AuthController],
  providers: [AuthService, EmailService],
})
export class AuthModule {}
src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
 
class EmailService {}
 
@Module({
  controllers: [AuthController],
  providers: [AuthService, EmailService],
})
export class AuthModule {}

Client-side (Duck Query)

Full query docs: /duck-query.

client/auth.query.ts
import { toast } from 'sonner'
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes, DuckGenI18nMessages, DuckgenScopedI18nByGroup } from '@gentleduck/gen/nestjs'
 
const D = createDuckQueryClient<ApiRoutes>()
 
export async function signinWithDuck(lang: 'ar' | 'en') {
  const { data } = await D.post(
    '/api/auth/signin',
    {
      body: {
        password: '123456',
        username: 'duck',
      },
    },
    { withCredentials: true },
  )
 
  if (data.state === 'error') {
    toast.error(i18n[lang].server.AuthMessages[data.message])
    return null
  }
 
  return data.data
}
 
type I18n = DuckgenScopedI18nByGroup<'ar' | 'en', DuckGenI18nMessages>
 
const i18n: I18n = {
  ar: {
    server: {
      AuthMessages: {
        AUTH_SIGNIN_SUCCESS: 'تم تسجيل الدخول بنجاح',
        AUTH_USERNAME_INVALID: 'اسم المستخدم غير صحيح',
        AUTH_PASSWORD_INVALID: 'كلمة المرور غير صحيحة',
        AUTH_SIGNIN_FAILED: 'فشل تسجيل الدخول',
 
        ZOD_EXPECTED_STRING: 'القيمة يجب أن تكون نصًا',
        ZOD_TOO_LONG: 'القيمة طويلة جدًا',
        ZOD_TOO_SHORT: 'القيمة قصيرة جدًا',
      },
    },
  },
  en: {
    server: {
      AuthMessages: {
        AUTH_SIGNIN_SUCCESS: 'Signed in successfully',
        AUTH_USERNAME_INVALID: 'Invalid username',
        AUTH_PASSWORD_INVALID: 'Invalid password',
        AUTH_SIGNIN_FAILED: 'Signin failed',
 
        ZOD_EXPECTED_STRING: 'Expected a string',
        ZOD_TOO_LONG: 'Too long',
        ZOD_TOO_SHORT: 'Too short',
      },
    },
  },
}
client/auth.query.ts
import { toast } from 'sonner'
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes, DuckGenI18nMessages, DuckgenScopedI18nByGroup } from '@gentleduck/gen/nestjs'
 
const D = createDuckQueryClient<ApiRoutes>()
 
export async function signinWithDuck(lang: 'ar' | 'en') {
  const { data } = await D.post(
    '/api/auth/signin',
    {
      body: {
        password: '123456',
        username: 'duck',
      },
    },
    { withCredentials: true },
  )
 
  if (data.state === 'error') {
    toast.error(i18n[lang].server.AuthMessages[data.message])
    return null
  }
 
  return data.data
}
 
type I18n = DuckgenScopedI18nByGroup<'ar' | 'en', DuckGenI18nMessages>
 
const i18n: I18n = {
  ar: {
    server: {
      AuthMessages: {
        AUTH_SIGNIN_SUCCESS: 'تم تسجيل الدخول بنجاح',
        AUTH_USERNAME_INVALID: 'اسم المستخدم غير صحيح',
        AUTH_PASSWORD_INVALID: 'كلمة المرور غير صحيحة',
        AUTH_SIGNIN_FAILED: 'فشل تسجيل الدخول',
 
        ZOD_EXPECTED_STRING: 'القيمة يجب أن تكون نصًا',
        ZOD_TOO_LONG: 'القيمة طويلة جدًا',
        ZOD_TOO_SHORT: 'القيمة قصيرة جدًا',
      },
    },
  },
  en: {
    server: {
      AuthMessages: {
        AUTH_SIGNIN_SUCCESS: 'Signed in successfully',
        AUTH_USERNAME_INVALID: 'Invalid username',
        AUTH_PASSWORD_INVALID: 'Invalid password',
        AUTH_SIGNIN_FAILED: 'Signin failed',
 
        ZOD_EXPECTED_STRING: 'Expected a string',
        ZOD_TOO_LONG: 'Too long',
        ZOD_TOO_SHORT: 'Too short',
      },
    },
  },
}

Run the generator

bunx duck-gen
bunx duck-gen

That emits ApiRoutes, DuckGenI18nMessages, and the scoped i18n types above.