TanStack Query активно использует возможности TypeScript для строгого
контроля данных запросов, мутаций, ошибок и кеша. Без корректной
типизации приложение быстро превращается в набор any, где
теряются преимущества автодополнения, проверки структуры API и контроля
ошибок на этапе компиляции.
Типизация в TanStack Query решает несколько задач:
queryKey;Основой любого запроса является queryFn. Именно её
возвращаемое значение определяет тип данных внутри
useQuery.
type User = {
id: number
name: string
email: string
}
async function fetchUser(): Promise<User> {
const response = await fetch('/api/user')
if (!response.ok) {
throw new Error('Ошибка загрузки')
}
return response.json()
}
Теперь useQuery автоматически выводит тип:
const query = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
Тип query.data:
User | undefined
Запрос выполняется асинхронно. До завершения загрузки данных ещё нет.
Поэтому:
query.data
всегда имеет вид:
TData | undefined
Даже если запрос гарантированно успешен.
Наиболее безопасный способ:
if (query.isSuccess) {
console.log(query.data.name)
}
После проверки isSuccess TypeScript автоматически сужает
тип.
Иногда TypeScript не способен вывести тип автоматически.
В этом случае generics задаются вручную.
Упрощённо:
useQuery<
TQueryFnData,
TError,
TData,
TQueryKey
>()
Это исходный тип, возвращаемый queryFn.
type Post = {
id: number
title: string
}
useQuery<Post[]>({
queryKey: ['posts'],
queryFn: fetchPosts,
})
По умолчанию ошибки имеют тип:
Error
Но API может возвращать собственный формат ошибок.
type ApiError = {
message: string
code: string
}
useQuery<Post[], ApiError>({
queryKey: ['posts'],
queryFn: fetchPosts,
})
Теперь:
query.error?.code
будет корректно типизирован.
select преобразует данные после получения из
запроса.
type User = {
id: number
name: string
email: string
}
useQuery<User[]>({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) => users.map((user) => user.name),
})
Теперь data становится:
string[] | undefined
Это один из важнейших аспектов типизации.
Тип данных, возвращаемых сервером.
Тип данных после select.
useQuery<User[], Error, string[]>({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) => users.map((u) => u.name),
})
Здесь:
| Generic | Значение |
|---|---|
TQueryFnData |
User[] |
TData |
string[] |
Ключи запросов являются критически важными для кеширования.
Неправильная структура ключей приводит к ошибкам инвалидации, коллизиям и проблемам синхронизации.
useQuery({
queryKey: ['users', 15],
queryFn: fetchUser,
})
Тип:
(string | number)[]
Но такой вариант слишком общий.
type UserQueryKey = ['users', number]
useQuery<User, Error, User, UserQueryKey>({
queryKey: ['users', 15],
queryFn: fetchUser,
})
Теперь TypeScript понимает:
'users';number.Контекст запроса автоматически получает типизированный
queryKey.
type UserQueryKey = ['users', number]
async function fetchUser(
context: QueryFunctionContext<UserQueryKey>
): Promise<User> {
const [, userId] = context.queryKey
const response = await fetch(`/api/users/${userId}`)
return response.json()
}
Теперь userId строго типизирован как
number.
TanStack Query предоставляет helper:
queryOptions()
Он помогает сохранять типы между различными API.
function userQueryOptions(id: number) {
return queryOptions({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})
}
Использование:
useQuery(userQueryOptions(5))
Также:
queryClient.prefetchQuery(userQueryOptions(5))
Типы полностью сохраняются.
Крупные проекты обычно используют фабрики запросов.
export const userQueries = {
all: () => ['users'] as const,
detail: (id: number) =>
['users', id] as const,
}
useQuery({
queryKey: userQueries.detail(5),
queryFn: () => fetchUser(5),
})
Преимущества:
По умолчанию метод возвращает unknown.
const data = queryClient.getQueryData(['users'])
Тип:
unknown
const data = queryClient.getQueryData<User[]>(['users'])
Теперь:
data?.[0].email
типизирован корректно.
queryClient.setQueryData<User[]>(
['users'],
(oldData) => {
return oldData ?? []
}
)
Бесконечные запросы имеют особую структуру.
type InfiniteData<T> = {
pages: T[]
pageParams: unknown[]
}
type PostsPage = {
items: Post[]
nextCursor?: string
}
const query = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
Тип:
InfiniteData<PostsPage>
async function fetchPosts({
pageParam,
}: QueryFunctionContext): Promise<PostsPage> {
const response = await fetch(
`/api/posts?cursor=${pageParam}`
)
return response.json()
}
type PageParam = string
QueryFunctionContext<
['posts'],
PageParam
>
placeholderData обязан соответствовать типу данных
запроса.
useQuery<User[]>({
queryKey: ['users'],
queryFn: fetchUsers,
placeholderData: [],
})
useQuery<User[]>({
queryKey: ['users'],
queryFn: fetchUsers,
initialData: [],
})
Становится частью кеша.
Используется временно и не кешируется.
Оба варианта строго типизируются через TData.
enabled не влияет на тип data.
Даже если:
enabled: false
тип остаётся:
TData | undefined
Это связано с тем, что запрос всё равно может не выполниться.
Suspense-версии меняют тип data.
const query = useQuery(...)
Тип:
data: TData | undefined
const query = useSuspenseQuery(...)
Тип:
data: TData
Без undefined.
Это возможно благодаря Suspense-гарантиям React.
Мутации имеют собственную generic-сигнатуру.
Упрощённо:
useMutation<
TData,
TError,
TVariables,
TContext
>()
type CreatePostDto = {
title: string
content: string
}
type Post = {
id: number
title: string
content: string
}
const mutation = useMutation<
Post,
Error,
CreatePostDto
>({
mutationFn: createPost,
})
Теперь:
mutation.mutate({
title: 'Новая статья',
content: 'Текст',
})
типизирован полностью.
Контекст мутации типизируется через TContext.
type MutationContext = {
previousPosts: Post[]
}
useMutation<
Post,
Error,
CreatePostDto,
MutationContext
>({
mutationFn: createPost,
onMutate: async (newPost) => {
const previousPosts =
queryClient.getQueryData<Post[]>(['posts']) ?? []
return { previousPosts }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['posts'],
context?.previousPosts
)
},
})
Axios автоматически поддерживает generics.
async function fetchUsers(): Promise<User[]> {
const response = await axios.get<User[]>(
'/api/users'
)
return response.data
}
У fetch generics отсутствуют.
Поэтому приходится указывать тип вручную.
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users')
return response.json() as Promise<User[]>
}
Без приведения типов:
response.json()
возвращает:
any
Это ломает безопасность всей цепочки типов.
TypeScript проверяет только compile-time типы.
Сервер может вернуть неверную структуру.
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
})
type User = z.infer<typeof UserSchema>
async function fetchUser(): Promise<User> {
const response = await fetch('/api/user')
const data = await response.json()
return UserSchema.parse(data)
}
Теперь:
Серверные ошибки часто имеют сложную структуру.
type ValidationError = {
message: string
fields: Record<string, string[]>
}
useMutation<
User,
ValidationError,
CreateUserDto
>({
mutationFn: createUser,
})
TanStack Query поддерживает произвольные metadata.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
meta: {
requiresAuth: true,
cacheGroup: 'users',
},
})
Можно расширить registry TanStack Query.
declare module '@tanstack/react-query' {
interface Register {
queryMeta: {
requiresAuth?: boolean
cacheGroup?: string
}
}
}
Теперь meta типизируется глобально.
TanStack Query позволяет описывать ключи на уровне всего приложения.
type AppQueryKey =
| ['users']
| ['users', number]
| ['posts']
| ['posts', number]
declare module '@tanstack/react-query' {
interface Register {
queryKey: AppQueryKey
}
}
Теперь некорректные ключи вызывают compile-time ошибки.
В v5 появился skipToken.
const query = useQuery({
queryKey: userId
? ['users', userId]
: skipToken,
queryFn: fetchUser,
})
Преимущества:
enabled;const userQuery = useQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
const projectsQuery = useQuery({
queryKey: ['projects', userQuery.data?.id],
queryFn: getProjectsByUser,
enabled: !!userQuery.data,
})
TypeScript продолжает учитывать возможность
undefined.
Оператор satisfies особенно полезен при создании query
factories.
const userKey = ['users', 15] as const satisfies QueryKey
Преимущества:
Обычно TanStack Query скрывают внутри собственных hooks.
export function useUser(id: number) {
return useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})
}
TypeScript автоматически сохраняет типы.
Иногда полезно фиксировать возвращаемый тип.
export function useUser(
id: number
): UseQueryResult<User, Error> {
return useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})
}
Частая ошибка — ручное указание всех generics без необходимости.
useQuery<
User[],
Error,
User[],
['users']
>({
queryKey: ['users'],
queryFn: fetchUsers,
})
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
TypeScript самостоятельно выведет типы.
Явное указание типов оправдано при:
select;queryKey;Крупные приложения обычно строят архитектуру следующим образом:
type UserDto = {
id: number
name: string
}
async function getUsers(): Promise<UserDto[]> {
...
}
const userQueries = {
all: () => ['users'] as const,
}
export function useUsers() {
return useQuery({
queryKey: userQueries.all(),
queryFn: getUsers,
})
}
Такая структура обеспечивает: