| name | TanStack Types |
| description | This skill should be used when the user asks about "type-safe React Query", "queryOptions typing", "Zod validation", "TypeScript with TanStack Query", "generic useQuery", "DataTag", "infer query types", or needs guidance on type safety patterns, runtime validation, and TypeScript best practices for TanStack Query. |
| version | 1.0.0 |
TanStack Query Type Safety Patterns
This skill provides guidance for achieving type safety in TanStack Query, covering queryOptions, runtime validation with Zod, and TypeScript best practices based on TKDodo's recommendations.
Core Principle: Trust in Types
The foundation of type safety is trust in type definitions. Without trust, TypeScript becomes just a linter that can be silenced.
"To truly leverage the power of TypeScript, there is one thing that you need above all: Trust."
The Anti-Pattern: Manual Generics
Avoid passing type parameters directly to useQuery:
const { data } = useQuery<Todo>({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
})
This violates the "golden rule of generics": For a generic to be useful, it must appear at least twice.
The Pattern: Type the Data Source
Instead of typing the consumer, type the source:
const fetchTodo = async (id: string): Promise<Todo> => {
const response = await axios.get(`/todos/${id}`)
return response.data
}
const { data } = useQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
})
The queryOptions Helper
The queryOptions() helper provides compile-time type safety:
import { queryOptions, useQuery } from '@tanstack/react-query'
const todoQueryOptions = (id: string) => queryOptions({
queryKey: ['todos', id] as const,
queryFn: async (): Promise<Todo> => {
const response = await api.get(`/todos/${id}`)
return response.data
},
staleTime: 5 * 60 * 1000,
})
const { data } = useQuery(todoQueryOptions(id))
const bad = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
stallTime: 5000,
})
DataTag for getQueryData/setQueryData
queryOptions enables type-safe cache access:
const options = todoQueryOptions(id)
const cachedTodo = queryClient.getQueryData(options.queryKey)
queryClient.setQueryData(options.queryKey, (old) => {
return old ? { ...old, completed: true } : old
})
Runtime Validation with Zod
The network boundary is inherently untrustworthy. Validate responses at runtime:
import { z } from 'zod'
const todoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
createdAt: z.string().datetime(),
})
type Todo = z.infer<typeof todoSchema>
const fetchTodo = async (id: string): Promise<Todo> => {
const response = await axios.get(`/todos/${id}`)
return todoSchema.parse(response.data)
}
Benefits of Runtime Validation
- Catches mismatches early: API changes are caught immediately
- Descriptive errors: Zod provides clear error messages
- Triggers error state: Invalid data triggers React Query's error handling
- Self-documenting: Schema serves as documentation
List Validation
const todosSchema = z.array(todoSchema)
const fetchTodos = async (): Promise<Todo[]> => {
const response = await axios.get('/todos')
return todosSchema.parse(response.data)
}
Partial/Optional Fields
const todoSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
metadata: z.record(z.string()).nullable(),
})
Query Factories with Full Type Safety
Combine queryOptions with factory pattern:
import { queryOptions } from '@tanstack/react-query'
import { z } from 'zod'
const todoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
})
const todosSchema = z.array(todoSchema)
export type Todo = z.infer<typeof todoSchema>
export const todoQueries = {
all: () => queryOptions({
queryKey: ['todos'] as const,
queryFn: async () => {
const res = await api.get('/todos')
return todosSchema.parse(res.data)
},
}),
detail: (id: string) => queryOptions({
queryKey: ['todos', 'detail', id] as const,
queryFn: async () => {
const res = await api.get(`/todos/${id}`)
return todoSchema.parse(res.data)
},
staleTime: 5 * 60 * 1000,
}),
byStatus: (status: 'active' | 'completed') => queryOptions({
queryKey: ['todos', 'status', status] as const,
queryFn: async () => {
const res = await api.get(`/todos?status=${status}`)
return todosSchema.parse(res.data)
},
}),
}
Typing Selectors
When using select, type the output properly:
const { data: title } = useQuery({
...todoQueryOptions(id),
select: (data) => data.title,
})
const productOptions = <TData = Product>(
id: string,
select?: (data: Product) => TData
) => queryOptions({
queryKey: ['products', id] as const,
queryFn: () => fetchProduct(id),
select,
})
const { data: product } = useQuery(productOptions(id))
const { data: title } = useQuery(productOptions(id, (p) => p.title))
Typing Mutations
Apply the same patterns to mutations:
const createTodoSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
})
type CreateTodoInput = z.infer<typeof createTodoSchema>
const useCreateTodo = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateTodoInput) => {
const validated = createTodoSchema.parse(input)
const res = await api.post('/todos', validated)
return todoSchema.parse(res.data)
},
onSuccess: (newTodo) => {
queryClient.setQueryData(
todoQueries.detail(newTodo.id).queryKey,
newTodo
)
},
})
}
Error Types
Type your errors for better error handling:
interface ApiError {
code: string
message: string
details?: Record<string, string[]>
}
const apiErrorSchema = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.array(z.string())).optional(),
})
const apiFetch = async <T>(
url: string,
schema: z.ZodType<T>
): Promise<T> => {
const response = await fetch(url)
if (!response.ok) {
const error = await response.json()
throw apiErrorSchema.parse(error)
}
return schema.parse(await response.json())
}
const { data, error } = useQuery({
queryKey: ['todos'],
queryFn: () => apiFetch('/todos', todosSchema),
})
Quick Reference
| Goal | Pattern |
|---|
| Type query data | Type the queryFn return, not useQuery generic |
| Catch typos | Use queryOptions() helper |
| Type-safe cache access | Use queryOptions with getQueryData/setQueryData |
| Runtime validation | Parse with Zod in queryFn |
| Reusable queries | Query factories with queryOptions |
| Typed selectors | Generic parameter on factory function |
Common Mistakes
1. Using Generics as Type Assertions
const { data } = useQuery<Todo>({
queryKey: ['todo'],
queryFn: () => fetch('/todo').then(r => r.json()),
})
const { data } = useQuery({
queryKey: ['todo'],
queryFn: () => fetch('/todo').then(r => r.json()).then(todoSchema.parse),
})
2. Not Using as const for Query Keys
queryKey: ['todos', id]
queryKey: ['todos', id] as const
Additional Resources
Reference Files
For detailed patterns and advanced techniques, consult:
references/advanced-typing.md - Complex type scenarios
Related Skills
- tanstack-query - Core concepts, query factories
- tanstack-mutations - Type-safe mutations
- tanstack-errors - Typed error handling