Типизация данных запросов

TanStack Query активно использует возможности TypeScript для строгого контроля данных запросов, мутаций, ошибок и кеша. Без корректной типизации приложение быстро превращается в набор any, где теряются преимущества автодополнения, проверки структуры API и контроля ошибок на этапе компиляции.

Типизация в TanStack Query решает несколько задач:

  • гарантирует корректную структуру ответа сервера;
  • предотвращает ошибки доступа к несуществующим полям;
  • обеспечивает безопасную работу с queryKey;
  • улучшает DX за счёт автокомплита;
  • упрощает рефакторинг;
  • позволяет централизованно описывать API-модели.

Типизация функции запроса

Основой любого запроса является 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

Почему data имеет undefined

Запрос выполняется асинхронно. До завершения загрузки данных ещё нет.

Поэтому:

query.data

всегда имеет вид:

TData | undefined

Даже если запрос гарантированно успешен.


Проверка статуса перед использованием data

Наиболее безопасный способ:

if (query.isSuccess) {
  console.log(query.data.name)
}

После проверки isSuccess TypeScript автоматически сужает тип.


Явное указание generic-параметров

Иногда TypeScript не способен вывести тип автоматически.

В этом случае generics задаются вручную.

Сигнатура useQuery

Упрощённо:

useQuery<
  TQueryFnData,
  TError,
  TData,
  TQueryKey
>()

Типизация TQueryFnData

Это исходный тип, возвращаемый 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

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

Разница между TQueryFnData и TData

Это один из важнейших аспектов типизации.

TQueryFnData

Тип данных, возвращаемых сервером.

TData

Тип данных после select.


Пример

useQuery<User[], Error, string[]>({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.map((u) => u.name),
})

Здесь:

Generic Значение
TQueryFnData User[]
TData string[]

Типизация queryKey

Ключи запросов являются критически важными для кеширования.

Неправильная структура ключей приводит к ошибкам инвалидации, коллизиям и проблемам синхронизации.


Простая типизация queryKey

useQuery({
  queryKey: ['users', 15],
  queryFn: fetchUser,
})

Тип:

(string | number)[]

Но такой вариант слишком общий.


Строгая типизация queryKey

Использование tuple

type UserQueryKey = ['users', number]
useQuery<User, Error, User, UserQueryKey>({
  queryKey: ['users', 15],
  queryFn: fetchUser,
})

Теперь TypeScript понимает:

  • первый элемент всегда 'users';
  • второй всегда number.

Типизация queryFnContext

Контекст запроса автоматически получает типизированный 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.


Типизация queryOptions

TanStack Query предоставляет helper:

queryOptions()

Он помогает сохранять типы между различными API.


Пример

function userQueryOptions(id: number) {
  return queryOptions({
    queryKey: ['users', id],
    queryFn: () => fetchUser(id),
  })
}

Использование:

useQuery(userQueryOptions(5))

Также:

queryClient.prefetchQuery(userQueryOptions(5))

Типы полностью сохраняются.


Централизация типизации запросов

Крупные проекты обычно используют фабрики запросов.


Query Factory Pattern

export const userQueries = {
  all: () => ['users'] as const,

  detail: (id: number) =>
    ['users', id] as const,
}

Использование

useQuery({
  queryKey: userQueries.detail(5),
  queryFn: () => fetchUser(5),
})

Преимущества:

  • единая структура ключей;
  • отсутствие строковых дубликатов;
  • строгая типизация;
  • безопасная инвалидация.

Типизация queryClient.getQueryData

По умолчанию метод возвращает unknown.

Без типизации

const data = queryClient.getQueryData(['users'])

Тип:

unknown

Указание типа вручную

const data = queryClient.getQueryData<User[]>(['users'])

Теперь:

data?.[0].email

типизирован корректно.


Типизация setQueryData

queryClient.setQueryData<User[]>(
  ['users'],
  (oldData) => {
    return oldData ?? []
  }
)

Типизация infinite queries

Бесконечные запросы имеют особую структуру.


InfiniteData

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>

Типизация pageParam

async function fetchPosts({
  pageParam,
}: QueryFunctionContext): Promise<PostsPage> {
  const response = await fetch(
    `/api/posts?cursor=${pageParam}`
  )

  return response.json()
}

Явная типизация pageParam

type PageParam = string
QueryFunctionContext<
  ['posts'],
  PageParam
>

Типизация placeholderData

placeholderData обязан соответствовать типу данных запроса.

Пример

useQuery<User[]>({
  queryKey: ['users'],
  queryFn: fetchUsers,
  placeholderData: [],
})

Типизация initialData

useQuery<User[]>({
  queryKey: ['users'],
  queryFn: fetchUsers,
  initialData: [],
})

Отличие initialData от placeholderData

initialData

Становится частью кеша.

placeholderData

Используется временно и не кешируется.

Оба варианта строго типизируются через TData.


Типизация enabled

enabled не влияет на тип data.

Даже если:

enabled: false

тип остаётся:

TData | undefined

Это связано с тем, что запрос всё равно может не выполниться.


Типизация useSuspenseQuery

Suspense-версии меняют тип data.


Обычный useQuery

const query = useQuery(...)

Тип:

data: TData | undefined

useSuspenseQuery

const query = useSuspenseQuery(...)

Тип:

data: TData

Без undefined.

Это возможно благодаря Suspense-гарантиям React.


Типизация мутаций

Мутации имеют собственную generic-сигнатуру.


Сигнатура useMutation

Упрощённо:

useMutation<
  TData,
  TError,
  TVariables,
  TContext
>()

Типизация variables

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: 'Текст',
})

типизирован полностью.


Типизация optimistic updates

Контекст мутации типизируется через 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

Axios автоматически поддерживает generics.


Пример

async function fetchUsers(): Promise<User[]> {
  const response = await axios.get<User[]>(
    '/api/users'
  )

  return response.data
}

Типизация fetch

У fetch generics отсутствуют.

Поэтому приходится указывать тип вручную.


Пример

async function fetchUsers(): Promise<User[]> {
  const response = await fetch('/api/users')

  return response.json() as Promise<User[]>
}

Проблема any при response.json

Без приведения типов:

response.json()

возвращает:

any

Это ломает безопасность всей цепочки типов.


Использование zod для runtime-валидации

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)
}

Теперь:

  • runtime проверяет серверный ответ;
  • TypeScript знает точную структуру.

Типизация ошибок API

Серверные ошибки часто имеют сложную структуру.


Пример

type ValidationError = {
  message: string
  fields: Record<string, string[]>
}

Использование

useMutation<
  User,
  ValidationError,
  CreateUserDto
>({
  mutationFn: createUser,
})

Типизация query meta

TanStack Query поддерживает произвольные metadata.


Пример

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,

  meta: {
    requiresAuth: true,
    cacheGroup: 'users',
  },
})

Глобальная типизация meta

Можно расширить registry TanStack Query.

declare module '@tanstack/react-query' {
  interface Register {
    queryMeta: {
      requiresAuth?: boolean
      cacheGroup?: string
    }
  }
}

Теперь meta типизируется глобально.


Глобальная типизация queryKey

TanStack Query позволяет описывать ключи на уровне всего приложения.


Пример

type AppQueryKey =
  | ['users']
  | ['users', number]
  | ['posts']
  | ['posts', number]

Регистрация типов

declare module '@tanstack/react-query' {
  interface Register {
    queryKey: AppQueryKey
  }
}

Теперь некорректные ключи вызывают compile-time ошибки.


Типизация skipToken

В v5 появился skipToken.


Пример

const query = useQuery({
  queryKey: userId
    ? ['users', userId]
    : skipToken,

  queryFn: fetchUser,
})

Преимущества:

  • безопасная условная типизация;
  • отсутствие enabled;
  • корректный контроль queryKey.

Типизация dependent queries

Пример

const userQuery = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})
const projectsQuery = useQuery({
  queryKey: ['projects', userQuery.data?.id],
  queryFn: getProjectsByUser,
  enabled: !!userQuery.data,
})

TypeScript продолжает учитывать возможность undefined.


Использование satisfies

Оператор satisfies особенно полезен при создании query factories.


Пример

const userKey = ['users', 15] as const satisfies QueryKey

Преимущества:

  • проверка совместимости;
  • сохранение literal-типов;
  • отсутствие лишнего расширения типов.

Типизация custom hooks

Обычно TanStack Query скрывают внутри собственных hooks.


Пример

export function useUser(id: number) {
  return useQuery({
    queryKey: ['users', id],
    queryFn: () => fetchUser(id),
  })
}

TypeScript автоматически сохраняет типы.


Явное описание return type

Иногда полезно фиксировать возвращаемый тип.

export function useUser(
  id: number
): UseQueryResult<User, Error> {
  return useQuery({
    queryKey: ['users', id],
    queryFn: () => fetchUser(id),
  })
}

Проблема избыточных generic-параметров

Частая ошибка — ручное указание всех generics без необходимости.


Плохой пример

useQuery<
  User[],
  Error,
  User[],
  ['users']
>({
  queryKey: ['users'],
  queryFn: fetchUsers,
})

Хороший пример

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
})

TypeScript самостоятельно выведет типы.


Когда generics действительно нужны

Явное указание типов оправдано при:

  • использовании select;
  • сложной типизации ошибок;
  • кастомных queryKey;
  • фабриках запросов;
  • сложных мутациях;
  • reusable utilities;
  • глобальных registry-типах.

Практика построения типизированного API-слоя

Крупные приложения обычно строят архитектуру следующим образом:

DTO

type UserDto = {
  id: number
  name: string
}

API

async function getUsers(): Promise<UserDto[]> {
  ...
}

Query Factory

const userQueries = {
  all: () => ['users'] as const,
}

Hook

export function useUsers() {
  return useQuery({
    queryKey: userQueries.all(),
    queryFn: getUsers,
  })
}

Такая структура обеспечивает:

  • единый источник типов;
  • предсказуемость кеша;
  • безопасный рефакторинг;
  • минимизацию дублирования;
  • полную совместимость с IDE и автокомплитом.