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 axiosAxios is a peer dependency. If it is already in your project, you only need @gentleduck/duck-query.
Create a typed client
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 UserDtoPOST requests
// Create a user
const newUser = await api.post('/api/users', {
body: {
name: 'Alice',
email: 'alice@example.com',
password: 'secure123',
},
})
// newUser.data is UserDtoPUT 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 nullNotice that Duck Query handles path parameter substitution automatically.
When you pass params: { id: '123' } for the path /api/users/:id, Duck Query
replaces :id with 123 in the actual URL.
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, andheadersfields 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:
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
| Approach | Lines of type code | Type safety | Maintenance |
|---|---|---|---|
| Manual Axios types | ~50 per module | Partial (can drift) | High |
| Generated types + manual Axios | ~10 per module | Full | Medium |
| Duck Query | ~3 per module | Full | Low |