Skip to main content
Search...

Chapter 7: Duck Query Client

Set up Duck Query for type-safe HTTP requests that use your generated route types.

What is Duck Query?

Duck Query is a lightweight HTTP client built on top of Axios that consumes the route types generated by Duck Gen. Instead of manually typing every API call, Duck Query reads your generated route map and provides full autocomplete and type checking automatically.

Install Duck Query

bun add @gentleduck/duck-query axios

Axios is a peer dependency. If it is already in your project, you only need @gentleduck/duck-query.

Create a typed client

client/api/client.ts
import { createDuckQuery } from '@gentleduck/duck-query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
 
export const api = createDuckQuery<ApiRoutes>({
  baseURL: 'http://localhost:3000',
})

That is the entire setup. The api client now knows every route, every request shape, and every response type.

Making typed requests

GET requests

// List users with query parameters
const users = await api.get('/api/users', {
  query: { page: 1, limit: 10 },
})
// users.data is UserDto[]
// users.message is UserMessage
// users.success is boolean
 
// Get a single user
const user = await api.get('/api/users/:id', {
  params: { id: '123' },
})
// user.data is UserDto

POST requests

// Create a user
const newUser = await api.post('/api/users', {
  body: {
    name: 'Alice',
    email: 'alice@example.com',
    password: 'secure123',
  },
})
// newUser.data is UserDto

PUT requests

// Update a user
const updated = await api.put('/api/users/:id', {
  params: { id: '123' },
  body: {
    name: 'Alice Updated',
  },
})

DELETE requests

// Delete a user
const result = await api.del('/api/users/:id', {
  params: { id: '123' },
})
// result.data is null

What you get for free

With Duck Query, TypeScript enforces:

  • Path autocomplete: Only valid route paths are accepted.
  • Method constraints: api.post() only accepts paths that support POST.
  • Request shape: The body, query, params, and headers fields match exactly what the server expects.
  • Response types: The return type matches exactly what the server sends back.
// TypeScript error: '/api/users' does not support DELETE
api.del('/api/users', { params: {} })
 
// TypeScript error: missing required field 'password'
api.post('/api/users', {
  body: { name: 'Alice', email: 'alice@example.com' },
})
 
// TypeScript error: '/api/nonexistent' is not a valid path
api.get('/api/nonexistent')

Using with React Query

Duck Query integrates naturally with TanStack React Query:

client/hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
 
export function useUsers(page: number = 1) {
  return useQuery({
    queryKey: ['users', page],
    queryFn: () => api.get('/api/users', { query: { page, limit: 10 } }),
  })
}
 
export function useCreateUser() {
  const queryClient = useQueryClient()
 
  return useMutation({
    mutationFn: (body: { name: string; email: string; password: string }) =>
      api.post('/api/users', { body }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

The return types of useQuery and useMutation are fully inferred from the generated route types. No manual type annotations needed.

Comparing approaches

ApproachLines of type codeType safetyMaintenance
Manual Axios types~50 per modulePartial (can drift)High
Generated types + manual Axios~10 per moduleFullMedium
Duck Query~3 per moduleFullLow

Next

Chapter 8: Real World Patterns