Строгая типизация query functions

queryFn — центральный элемент любого запроса в TanStack Query. Именно эта функция отвечает за получение данных, обработку параметров запроса, генерацию ошибок и возврат результата в кеш Query Client.

В TypeScript строгая типизация queryFn особенно важна по нескольким причинам:

  • корректный вывод типов в data
  • безопасная работа с параметрами queryKey
  • строгая типизация ошибок
  • поддержка автодополнения
  • защита от несовместимых структур API
  • корректная интеграция с select, placeholderData, initialData
  • типобезопасность при SSR и hydration

Без строгой типизации queryFn TanStack Query быстро превращается в набор unknown, any и небезопасных преобразований.


Базовая типизация queryFn

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

type User = {
    id: number
    name: string
    email: string
}

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

    if (!response.ok) {
        throw new Error('Failed to fetch users')
    }

    return response.json()
}

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

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

TypeScript автоматически выводит:

query.data // User[] | undefined

Почему нельзя использовать any

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

const fetchUsers = async (): Promise<any> => {
    const response = await fetch('/api/users')
    return response.json()
}

Последствия:

query.data.foo.bar.baz

TypeScript не выдаст ошибку.

Проблемы:

  • потеря контроля структуры
  • отсутствие безопасного рефакторинга
  • скрытые ошибки API
  • поломка автодополнения
  • невозможность корректной типизации select

Явное указание возвращаемого типа

Типизация Promise

Лучший подход:

const fetchPosts = async (): Promise<Post[]> => {
    const response = await fetch('/api/posts')

    if (!response.ok) {
        throw new Error('Request failed')
    }

    return response.json()
}

Худший подход:

const fetchPosts = async () => {
    return await fetch('/api/posts').then(r => r.json())
}

Во втором случае TypeScript часто выводит:

Promise<any>

Типизация response.json()

Проблема json()

Метод:

response.json()

имеет тип:

Promise<any>

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


Безопасное приведение

const fetchUser = async (): Promise<User> => {
    const response = await fetch('/api/user')

    if (!response.ok) {
        throw new Error('Failed')
    }

    const data: User = await response.json()

    return data
}

Универсальная типизация HTTP-клиента

Generic fetch helper

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

async function fetchJson<T>(url: string): Promise<T> {
    const response = await fetch(url)

    if (!response.ok) {
        throw new Error('Request failed')
    }

    return response.json() as Promise<T>
}

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

const fetchUsers = () => {
    return fetchJson<User[]>('/api/users')
}

Типизация queryFnContext

Структура QueryFunctionContext

TanStack Query передаёт в queryFn специальный контекст:

type QueryFunctionContext = {
    queryKey: QueryKey
    signal: AbortSignal
    meta: QueryMeta | undefined
    pageParam?: unknown
}

Типизация queryKey

Небезопасный вариант

const fetchUser = async ({ queryKey }: QueryFunctionContext) => {
    const [, userId] = queryKey

    const response = await fetch(`/api/users/${userId}`)

    return response.json()
}

Проблемы:

  • userId имеет тип unknown
  • отсутствует контроль структуры ключа

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

Tuple-тип

type UserQueryKey = ['user', number]

Типизированный queryFn

const fetchUser = async (
    context: QueryFunctionContext<UserQueryKey>
): Promise<User> => {
    const [, userId] = context.queryKey

    const response = await fetch(`/api/users/${userId}`)

    if (!response.ok) {
        throw new Error('Failed')
    }

    return response.json()
}

Теперь:

userId // number

Inline-типизация queryFn

Типизация прямо в useQuery

useQuery<User>({
    queryKey: ['user', 15],
    queryFn: async (): Promise<User> => {
        const response = await fetch('/api/user/15')

        return response.json()
    },
})

Подход работает, но имеет недостатки:

  • сложнее переиспользование
  • дублирование логики
  • ухудшение читаемости
  • плохая масштабируемость

Вывод типов из queryFn

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

Пример

const fetchTodos = async (): Promise<Todo[]> => {
    return fetchJson('/api/todos')
}

const query = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
})

TypeScript автоматически выводит:

query.data // Todo[] | undefined

Проблемы автоматического вывода

Сложные uni on-типы

const fetchData = async (): Promise<User[] | null> => {
    return fetchJson('/api/users')
}

Теперь:

query.data // User[] | null | undefined

Появляется тройная неопределённость:

  • undefined
  • null
  • массив

Предпочтение undefined вместо null

В TanStack Query обычно лучше:

Promise<User[]>

а не:

Promise<User[] | null>

Поскольку библиотека уже использует undefined как состояние отсутствующих данных.


Типизация ошибок в queryFn

Стандартный Error

const fetchProducts = async (): Promise<Product[]> => {
    const response = await fetch('/api/products')

    if (!response.ok) {
        throw new Error('Products request failed')
    }

    return response.json()
}

Кастомные ошибки

ApiError

class ApiError extends Error {
    status: number

    constructor(message: string, status: number) {
        super(message)

        this.status = status
    }
}

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

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

    if (!response.ok) {
        throw new ApiError(
            'Failed to fetch users',
            response.status
        )
    }

    return response.json()
}

Типизация error в useQuery

const query = useQuery<User[], ApiError>({
    queryKey: ['users'],
    queryFn: fetchUsers,
})

Теперь:

query.error?.status

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


Типизация queryFn с Axios

AxiosResponse

import axios from 'axios'

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

    return response.data
}

Ошибки Axios

AxiosError

import axios, { AxiosError } from 'axios'

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

Типизированный запрос

const query = useQuery<
    User[],
    AxiosError<ApiValidationError>
>({
    queryKey: ['users'],
    queryFn: fetchUsers,
})

Типизация queryFn с параметрами

Параметры через queryKey

type UserDetailsKey = ['user-details', number]

queryFn

const fetchUserDetails = async (
    context: QueryFunctionContext<UserDetailsKey>
): Promise<UserDetails> => {
    const [, userId] = context.queryKey

    return fetchJson<UserDetails>(
        `/api/users/${userId}`
    )
}

Query Key Factory

Централизованные ключи

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

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

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

useQuery({
    queryKey: userKeys.detail(10),
    queryFn: fetchUser,
})

Типизация через ReturnType

Автоматическая связка типов

type UserDetailKey =
    ReturnType<typeof userKeys.detail>

QueryFunctionContext

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

    return fetchJson(`/api/users/${id}`)
}

Типизация optional-параметров

Проблемный вариант

['users', filters]

где:

filters?: UserFilters

Теперь ключ может содержать:

undefined

Лучший подход

Стабильная структура

type UsersKey = [
    'users',
    {
        role?: string
        active?: boolean
    }
]

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

useQuery({
    queryKey: [
        'users',
        {
            role: 'admin',
            active: true,
        },
    ],
    queryFn: fetchUsers,
})

Типизация signal

TanStack Query автоматически отменяет запросы через AbortController.

Типизированный signal

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

    return response.json()
}

Обработка AbortError

try {
    const response = await fetch('/api/users', {
        signal,
    })

    return response.json()
} catch (error) {
    if (error instanceof DOMException) {
        if (error.name === 'AbortError') {
            throw error
        }
    }

    throw error
}

Типизация infinite queries

pageParam

type ProjectsResponse = {
    items: Project[]
    nextPage?: number
}

QueryFunctionContext

const fetchProjects = async ({
    pageParam = 1,
}: QueryFunctionContext): Promise<ProjectsResponse> => {
    return fetchJson(
        `/api/projects?page=${pageParam}`
    )
}

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

const fetchProjects = async ({
    pageParam = 1,
}: QueryFunctionContext<
    ['projects'],
    number
>): Promise<ProjectsResponse> => {
    return fetchJson(
        `/api/projects?page=${pageParam}`
    )
}

Теперь:

pageParam // number

Типизация select

Исходный тип

type User = {
    id: number
    firstName: string
    lastName: string
}

select

const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    select: (users) =>
        users.map(user => ({
            ...user,
            fullName:
                `${user.firstName} ${user.lastName}`,
        })),
})

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


Ошибки select при плохой типизации queryFn

Если queryFn возвращает any:

select: (users) => users.foo.bar

ошибка не появится.


Типизация dependent queries

enabled

const userId: number | undefined = 10

Проблема

queryFn: () => fetchUser(userId)

TypeScript выдаёт ошибку:

number | undefined

Безопасное решение

useQuery({
    queryKey: ['user', userId],
    enabled: !!userId,
    queryFn: () => {
        if (!userId) {
            throw new Error('Missing userId')
        }

        return fetchUser(userId)
    },
})

Типизация queryOptions

Переиспользуемые options

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

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

useQuery(userQueryOptions(5))

TypeScript сохраняет строгую типизацию.


Типизация queryFn в SSR

Prefetch

await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
})

Типы автоматически сохраняются между:

  • SSR
  • hydration
  • useQuery

Типизация queryFn и placeholderData

Проблема несовместимости

placeholderData: []

Если queryFn возвращает:

User[]

всё корректно.

Но если:

User[] | null

возникают конфликтующие типы.


Типизация queryFn и initialData

initialData должен совпадать

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

Тип initialData обязан соответствовать возвращаемому типу queryFn.


Асинхронные преобразования внутри queryFn

Нормализация данных

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

    const users: ApiUser[] =
        await response.json()

    return users.map(user => ({
        id: user.id,
        name: user.full_name,
        email: user.email_address,
    }))
}

Очень полезный подход:

  • UI получает стабильную структуру
  • API-модель изолирована
  • упрощается миграция backend

Разделение API DTO и UI Model

DTO

type UserDto = {
    user_id: number
    user_name: string
}

UI Model

type User = {
    id: number
    name: string
}

Mapping

const fetchUsers = async (): Promise<User[]> => {
    const dto = await fetchJson<UserDto[]>(
        '/api/users'
    )

    return dto.map(user => ({
        id: user.user_id,
        name: user.user_name,
    }))
}

Никогда не типизировать queryFn как unknown

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

const fetchUsers = async (): Promise<unknown> => {
    return fetchJson('/api/users')
}

Теперь весь downstream-код ломается:

query.data?.map(...)

TypeScript запрещает операции.


Строгая типизация и runtime-валидация

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

Поэтому для критически важных API часто используется:

  • Zod
  • Valibot
  • io-ts

Пример с Zod

import { z } fr om 'zod'

const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string(),
})

type User = z.infer<typeof UserSchema>

Runtime validation

const fetchUser = async (): Promise<User> => {
    const response = await fetch('/api/user')

    const json = await response.json()

    return UserSchema.parse(json)
}

Теперь:

  • TypeScript проверяет compile-time
  • Zod проверяет runtime

Лучшие практики строгой типизации queryFn

Основные правила

Всегда указывать Promise

Promise<User[]>

а не:

Promise<any>

Никогда не использовать any

Особенно:

  • response.json() as any
  • Promise<any>
  • queryFn: async () => any

Типизировать queryKey через tuple

type UserKey = ['user', number]

Использовать QueryFunctionContext

QueryFunctionContext<UserKey>

Выделять HTTP-клиент

fetchJson<T>()

Разделять DTO и UI-модели

Это резко повышает стабильность frontend-кода.


Использовать runtime validation для критичных API

Особенно:

  • платежи
  • авторизация
  • permissions
  • billing
  • enterprise API
  • внешние интеграции

Архитектура типизированного query layer

Крупные проекты обычно строят структуру:

api/
    client.ts
    users.ts
    posts.ts

models/
    user.ts
    post.ts

queries/
    users/
        keys.ts
        queries.ts
        mutations.ts

Полностью типизированный production-пример

keys.ts

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

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

api.ts

export async function fetchJson<T>(
    url: string
): Promise<T> {
    const response = await fetch(url)

    if (!response.ok) {
        throw new Error('Request failed')
    }

    return response.json()
}

queries.ts

type User = {
    id: number
    name: string
}

type UserKey =
    ReturnType<typeof userKeys.detail>

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

    return fetchJson<User>(
        `/api/users/${id}`
    )
}

component.tsx

const userQuery = useQuery({
    queryKey: userKeys.detail(5),
    queryFn: fetchUser,
})

userQuery.data?.name
userQuery.error

В результате получается:

  • строгая типизация данных
  • строгая типизация ключей
  • безопасный рефакторинг
  • автодополнение
  • защита от ошибок API
  • предсказуемое поведение кеша
  • полная совместимость с SSR
  • масштабируемая архитектура query layer