Типизация query keys

В библиотеке TanStack Query ключ запроса (queryKey) является центральным механизмом идентификации данных в кеше. Каждый запрос сохраняется и извлекается по уникальному ключу. От качества проектирования ключей напрямую зависит:

  • корректность кеширования;
  • переиспользование данных;
  • инвалидация кеша;
  • предсказуемость обновлений;
  • работа SSR и hydration;
  • поддерживаемость приложения.

В JavaScript ключи являются динамическими и могут формироваться практически любым способом. Однако при использовании TypeScript появляется возможность строго типизировать структуру queryKey, что значительно уменьшает количество ошибок.


Базовая структура query key

Наиболее распространённый формат:

['users']
['users', userId]
['users', userId, 'posts']
['posts', { page: 1, limit: 20 }]

TanStack Query воспринимает ключ как сериализуемую структуру данных.

На практике ключ почти всегда представляет массив.


Проблемы отсутствия типизации

Без строгой типизации возникают типичные ошибки:

useQuery({
  queryKey: ['user', id],
})

В другом месте:

queryClient.invalidateQueries({
  queryKey: ['users', id],
})

Ошибка в одном символе приводит к тому, что инвалидация не срабатывает.

Другой пример:

['posts', 5]

И:

['posts', '5']

Для TanStack Query это разные ключи.


Почему строковые литералы важны

TypeScript умеет сохранять точные значения строковых литералов:

const key = ['users']

Тип:

string[]

Но:

const key = ['users'] as const

Тип:

readonly ['users']

Во втором случае TypeScript понимает точное содержимое массива.


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

Без as const

const key = ['posts', 5]

TypeScript выводит:

(string | number)[]

Точная структура потеряна.


С as const

const key = ['posts', 5] as const

Тип:

readonly ['posts', 5]

Теперь:

  • порядок элементов известен;
  • литеральные значения сохранены;
  • массив readonly;
  • возможна строгая проверка.

Простейшая типизация query key

type UserKey = ['users', number]

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

const key: UserKey = ['users', 5]

Ошибка:

const key: UserKey = ['user', 5]

TypeScript:

Type '"user"' is not assignable to type '"users"'

Типизация через readonly tuple

TanStack Query активно использует readonly-массивы.

Поэтому лучше:

type UserKey = readonly ['users', number]

Или:

type UserKey = Readonly<['users', number]>

Типизация вложенных сегментов

Ключи часто имеют иерархическую структуру:

['users', userId, 'posts']

Тип:

type UserPostsKey = readonly [
  'users',
  number,
  'posts'
]

Теперь невозможно случайно поменять порядок:

['posts', 5, 'users']

Типизация объектных параметров

Очень распространённый формат:

['posts', { page: 1, limit: 20 }]

Тип:

type PostsFilters = {
  page: number
  limit: number
}

type PostsKey = readonly [
  'posts',
  PostsFilters
]

Проблема мутабельных объектов

Следующий код опасен:

const filters = {
  page: 1,
  limit: 20,
}

Позже:

filters.page = 2

Ключ изменился.

Для query keys желательно использовать неизменяемые объекты:

const filters = {
  page: 1,
  limit: 20,
} as const

Фабрики query keys

На больших проектах ключи почти всегда выносятся в отдельные фабрики.

Пример:

export const userKeys = {
  all: ['users'] as const,

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

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

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

useQuery({
  queryKey: userKeys.detail(5),
})

Инвалидация:

queryClient.invalidateQueries({
  queryKey: userKeys.all,
})

Преимущества фабрик

Централизация

Все ключи находятся в одном месте.


Автодополнение

IDE показывает доступные варианты:

userKeys.detail
userKeys.posts

Защита от опечаток

Нельзя случайно написать:

['usres']

Переиспользование

Ключи используются:

  • в запросах;
  • мутациях;
  • invalidateQueries;
  • prefetchQuery;
  • hydration.

Автоматический вывод типов

TypeScript умеет автоматически выводить tuple-типы:

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

Тип результата:

readonly ['users', number]

Извлечение типа query key

Иногда требуется получить тип автоматически:

type UserDetailKey =
  ReturnType<typeof userKeys.detail>

Результат:

readonly ['users', number]

Универсальные фабрики

Можно создавать generic-фабрики.

Пример:

const entityKeys = <T extends string>(entity: T) => ({
  all: [entity] as const,

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

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

const postKeys = entityKeys('posts')
const userKeys = entityKeys('users')

Типизация queryFn через queryKey

В queryFn можно получить typed query key:

useQuery({
  queryKey: ['users', 5] as const,

  queryFn: ({ queryKey }) => {
    const [, id] = queryKey

    return fetchUser(id)
  },
})

Однако TypeScript иногда теряет точность типов.


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

import type {
  QueryFunctionContext
} fr om '@tanstack/react-query'

type UserKey = readonly ['users', number]

const fetchUser = async (
  context: QueryFunctionContext<UserKey>
) => {
  const [, id] = context.queryKey

  return api.users.get(id)
}

Теперь id строго типизирован как number.


Типизация query options

При использовании queryOptions типизация становится особенно полезной.

import {
  queryOptions
} from '@tanstack/react-query'

const userQuery = (id: number) =>
  queryOptions({
    queryKey: ['users', id] as const,

    queryFn: () => fetchUser(id),
  })

Переиспользование query options

useQuery(userQuery(5))

Prefetch:

queryClient.prefetchQuery(userQuery(5))

Invalidate:

queryClient.invalidateQueries({
  queryKey: userQuery(5).queryKey,
})

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

В крупных приложениях полезно ограничить допустимые ключи глобально.


Регистрация QueryKey

TanStack Query позволяет переопределить глобальный тип QueryKey.

Пример:

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

Регистрация:

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

Что даёт глобальная регистрация

Теперь ошибка появляется сразу:

['unknown']

TypeScript сообщит, что ключ не входит в разрешённый набор.


Ограничение архитектуры приложения

Глобальная типизация позволяет:

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

Namespace-подход

На больших проектах часто используют namespace-структуру:

['users']
['users', 'detail', id]
['users', 'posts', id]

Тип:

type UserKeys =
  | readonly ['users']
  | readonly ['users', 'detail', number]
  | readonly ['users', 'posts', number]

Преимущества namespace-структуры

Более предсказуемая инвалидация

queryClient.invalidateQueries({
  queryKey: ['users'],
})

Инвалидирует все дочерние ключи.


Лучшая читаемость

['users', 'detail', 5]

Понятнее, чем:

['users', 5]

Недостатки избыточной вложенности

Слишком длинные ключи усложняют поддержку:

[
  'users',
  'detail',
  'posts',
  'comments',
  'likes',
  userId
]

Необходимо сохранять баланс между детализацией и простотой.


Типизация бесконечных запросов

Для useInfiniteQuery ключи также должны быть типизированы.

type FeedKey = readonly [
  'feed',
  {
    lim it: number
  }
]

Типизация параметров фильтрации

Распространённый вариант:

type ProductsFilters = {
  category?: string
  page?: number
  sort?: 'asc' | 'desc'
}

Ключ:

type ProductsKey = readonly [
  'products',
  ProductsFilters
]

Избежание нестабильных объектов

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

queryKey: [
  'products',
  {
    page,
    timestamp: Date.now(),
  },
]

Ключ меняется на каждом рендере.


Нестабильные функции в query key

Функции нельзя использовать в query keys:

['users', () => 5]

Ключи должны быть сериализуемыми.


Типизация параметров маршрутов

Часто query keys связаны с router params.

Пример:

type RouteParams = {
  userId: string
}

Преобразование:

const key = [
  'users',
  Number(params.userId),
] as const

Комбинирование query keys и domain models

type User = {
  id: number
  name: string
}

Фабрика:

const userKeys = {
  detail: (user: User) =>
    ['users', user.id] as const,
}

Выделение сегментов query keys

Иногда полезно хранить сегменты отдельно:

const USERS_KEY = 'users'
const POSTS_KEY = 'posts'

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

[USERS_KEY, id]

Однако чрезмерное дробление ухудшает читаемость.


Query key factories как архитектурный слой

На больших проектах фабрики превращаются в полноценный слой архитектуры.

Пример структуры:

src/
  shared/
    api/
      queryKeys/
        users.ts
        posts.ts
        comments.ts

Интеграция с feature-based architecture

Фабрики удобно хранить рядом с feature-модулями:

features/
  users/
    api/
      queryKeys.ts

Типизация invalidateQueries

queryClient.invalidateQueries({
  queryKey: userKeys.detail(5),
})

Без фабрики легко допустить несовпадение.


Типизация setQueryData

queryClient.setQueryData(
  userKeys.detail(5),
  user
)

Ключ используется повторно без дублирования.


Типизация getQueryData

const user = queryClient.getQueryData<User>(
  userKeys.detail(5)
)

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

Современный TypeScript позволяет использовать satisfies.

Пример:

const key = ['users', 5] as const
  satisfies QueryKey

Разница между as const и satisfies

as const

Фиксирует литеральные значения.


satisfies

Проверяет совместимость типов без изменения итогового типа.


Комбинирование as const и satisfies

const key =
  ['users', 5] as const
  satisfies QueryKey

Это один из наиболее безопасных вариантов.


Типизация динамических сегментов

type Locale = 'ru' | 'en'

type ArticleKey = readonly [
  'article',
  Locale,
  string
]

Типизация мультиарендных приложений

type TenantKey = readonly [
  'tenant',
  string,
  'users',
  number
]

Типизация версий API

type ApiVersion = 'v1' | 'v2'

type UsersKey = readonly [
  ApiVersion,
  'users',
  number
]

Ошибки слишком строгой типизации

Иногда разработчики создают чрезмерно сложные union-типы:

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

Подобные конструкции быстро становятся трудно поддерживаемыми.


Практический баланс

Наиболее распространённый подход:

  • фабрики query keys;
  • as const;
  • readonly tuple;
  • namespace-структура;
  • локальная типизация вместо глобальных гигантских union.

Рекомендуемый стиль

Хороший production-подход:

export const postKeys = {
  all: ['posts'] as const,

  lists: () =>
    ['posts', 'list'] as const,

  list: (filters: PostsFilters) =>
    ['posts', 'list', filters] as const,

  details: () =>
    ['posts', 'detail'] as const,

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

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

  • предсказуемость;
  • единый namespace;
  • удобная инвалидация;
  • строгая типизация;
  • хорошая масштабируемость;
  • совместимость с SSR и hydration;
  • удобная интеграция с DevTools.