Любой сетевой запрос может завершиться ошибкой. Сервер может вернуть
код 500, API может ответить структурой ошибки в собственном
формате, пользователь может потерять соединение с интернетом, а
клиентская библиотека — выбросить исключение ещё до выполнения
HTTP-запроса.
В TanStack Query ошибки являются частью жизненного цикла запроса и
мутации. Они участвуют в состоянии status, попадают в поля
error, передаются в onError, используются в
retry, throwOnError,
ErrorBoundary и механизмах глобальной обработки.
Главная сложность заключается в том, что Javascript допускает выброс любого значения:
throw new Error('Ошибка');
throw 'Ошибка';
throw 404;
throw { message: 'Ошибка' };
Из-за этого TanStack Query по умолчанию не может гарантировать конкретный тип ошибки.
error в useQueryТипичная структура запроса:
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
Поле ошибки:
query.error
В TypeScript оно обычно имеет тип:
unknown
или:
Error | null
в зависимости от версии библиотеки и конфигурации.
Это сделано намеренно. TanStack Query не знает, что именно
выбрасывает queryFn.
unknown лучше,
чем anyТип unknown заставляет явно проверять ошибку перед
использованием.
Небезопасный код:
if (query.error) {
console.log(query.error.message);
}
TypeScript выдаст ошибку:
Property 'message' does not exist on type 'unknown'
Безопасный вариант:
if (query.error instanceof Error) {
console.log(query.error.message);
}
Такой подход предотвращает огромное количество ошибок рантайма.
useQuery поддерживает generics:
useQuery<TQueryFnData, TError>()
Пример:
type ApiError = {
message: string;
code: number;
};
const query = useQuery<User[], ApiError>({
queryKey: ['users'],
queryFn: fetchUsers,
});
Теперь:
query.error
имеет тип:
ApiError | null
Это позволяет безопасно обращаться к свойствам:
if (query.error) {
console.log(query.error.code);
}
Современный useQuery содержит несколько
generic-параметров:
useQuery<
TQueryFnData,
TError,
TData,
TQueryKey
>()
Расшифровка:
| Generic | Назначение |
|---|---|
TQueryFnData |
исходные данные queryFn |
TError |
тип ошибки |
TData |
итоговые преобразованные данные |
TQueryKey |
тип queryKey |
Пример:
type ApiError = {
message: string;
status: number;
};
const query = useQuery<
UserResponse,
ApiError,
User[],
['users']
>({
queryKey: ['users'],
queryFn: fetchUsers,
select: (data) => data.items,
});
Типизация запроса бесполезна, если queryFn выбрасывает
хаотичные значения.
Плохой пример:
async function fetchUsers() {
const response = await fetch('/api/users');
if (!response.ok) {
throw 'Ошибка загрузки';
}
return response.json();
}
Ошибка имеет тип string.
Гораздо правильнее:
class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
Использование:
async function fetchUsers() {
const response = await fetch('/api/users');
if (!response.ok) {
throw new ApiError(
'Ошибка загрузки пользователей',
response.status
);
}
return response.json();
}
Теперь TanStack Query получает предсказуемый объект ошибки.
Использование кастомных классов — один из лучших подходов для крупных приложений.
Пример:
class ValidationError extends Error {
fields: Record<string, string>;
constructor(
message: string,
fields: Record<string, string>
) {
super(message);
this.fields = fields;
}
}
Проверка:
if (query.error instanceof ValidationError) {
console.log(query.error.fields);
}
Преимущества:
instanceofПри использовании Axios ошибки имеют специальный тип:
AxiosError
Пример:
import axios, { AxiosError } from 'axios';
type ApiErrorResponse = {
message: string;
};
const query = useQuery<User[], AxiosError<ApiErrorResponse>>({
queryKey: ['users'],
queryFn: async () => {
const response = await axios.get('/users');
return response.data;
},
});
Теперь доступны:
query.error?.response?.data.message
Безопасный способ проверки:
import axios from 'axios';
if (axios.isAxiosError(query.error)) {
console.log(query.error.response);
}
Это особенно важно, потому что instanceof AxiosError
работает не всегда корректно между разными сборками и пакетами.
TanStack Query позволяет определить глобальный тип ошибок через module augmentation.
Пример:
import '@tanstack/react-query';
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError;
}
}
После этого:
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
получит:
query.error // ApiError | null
без указания generic-параметра.
Все запросы используют одинаковую структуру ошибок.
Не требуется писать:
useQuery<Data, ApiError>()
в каждом запросе.
Можно построить общий механизм уведомлений:
toast.error(error.message);
без постоянных проверок типов.
Часто создают единый тип:
class ApiError extends Error {
status: number;
details?: unknown;
constructor(
message: string,
status: number,
details?: unknown
) {
super(message);
this.status = status;
this.details = details;
}
}
Использование:
throw new ApiError(
'Пользователь не найден',
404
);
useMutation также поддерживает TError.
Пример:
type ValidationError = {
message: string;
fields: Record<string, string>;
};
const mutation = useMutation<
User,
ValidationError,
CreateUserDto
>({
mutationFn: createUser,
});
Теперь:
mutation.error?.fields
типизировано корректно.
Пример:
useMutation({
mutationFn: createUser,
onError(error) {
console.log(error);
},
});
Без generic-параметров error может быть
unknown.
Типизированный вариант:
useMutation<User, ApiError, CreateUserDto>({
mutationFn: createUser,
onError(error) {
console.log(error.status);
},
});
Функция retry получает ошибку:
retry(failureCount, error)
Пример:
useQuery<User[], ApiError>({
queryKey: ['users'],
queryFn: fetchUsers,
retry(failureCount, error) {
if (error.status === 404) {
return false;
}
return failureCount < 3;
},
});
Опция:
throwOnError: true
заставляет TanStack Query пробрасывать ошибку в
ErrorBoundary.
Пример:
useQuery<User[], ApiError>({
queryKey: ['users'],
queryFn: fetchUsers,
throwOnError: true,
});
React Error Boundary получает:
error: Error
Но сервер может вернуть что угодно.
Поэтому безопаснее нормализовать ошибки заранее.
Хорошая практика — приводить любые ошибки к одному формату.
Пример:
function normalizeError(error: unknown): ApiError {
if (error instanceof ApiError) {
return error;
}
if (error instanceof Error) {
return new ApiError(error.message, 500);
}
return new ApiError(
'Неизвестная ошибка',
500
);
}
Использование:
async function fetchUsers() {
try {
const response = await api.get('/users');
return response.data;
} catch (error) {
throw normalizeError(error);
}
}
Нативный fetch не выбрасывает ошибки при HTTP
400/500.
Неправильный подход:
const response = await fetch('/users');
return response.json();
Даже при 500 запрос считается успешным.
Правильный вариант:
const response = await fetch('/users');
if (!response.ok) {
throw new ApiError(
'Ошибка загрузки',
response.status
);
}
GraphQL-серверы часто возвращают:
{
"errors": [
{
"message": "Unauthorized"
}
]
}
Удобно создать отдельный тип:
type GraphQLError = {
message: string;
path?: string[];
};
И общий контейнер:
type GraphQLResponseError = {
errors: GraphQLError[];
};
Иногда API возвращает разные типы ошибок.
Пример:
type AuthError = {
type: 'auth';
message: string;
};
type ValidationError = {
type: 'validation';
fields: Record<string, string>;
};
type ApiError =
| AuthError
| ValidationError;
Проверка:
if (error.type === 'validation') {
console.log(error.fields);
}
Лучший вариант union-типов — discriminated unions.
Пример:
type ApiError =
| {
type: 'network';
message: string;
}
| {
type: 'validation';
fields: Record<string, string>;
};
TypeScript автоматически сужает тип:
if (error.type === 'validation') {
error.fields;
}
Начиная с TypeScript 4.4:
catch (error)
получает тип unknown.
Поэтому нужен type narrowing:
catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
Можно создавать собственные type guards.
Пример:
function isApiError(
error: unknown
): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'status' in error
);
}
Использование:
if (isApiError(query.error)) {
console.log(query.error.status);
}
Можно обрабатывать ошибки централизованно.
Пример:
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError(error) {
console.log(error);
},
}),
});
Тип error обычно:
unknown
Поэтому желательно использовать нормализацию:
onError(error) {
const normalized = normalizeError(error);
toast.error(normalized.message);
}
Аналогично:
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError(error) {
console.log(error);
},
}),
});
При валидации серверных ответов удобно использовать Zod.
Пример:
const ErrorSchema = z.object({
message: z.string(),
status: z.number(),
});
Преобразование:
const parsed = ErrorSchema.parse(data);
TypeScript проверяет типы только во время компиляции.
Сервер может вернуть:
{
"msg": "Ошибка"
}
вместо:
{
"message": "Ошибка"
}
Поэтому runtime validation особенно важна для ошибок API.
useInfiniteQuery также поддерживает
TError.
Пример:
useInfiniteQuery<
PostsResponse,
ApiError
>({
queryKey: ['posts'],
queryFn: fetchPosts,
});
Во время optimistic update ошибка часто содержит rollback context.
Пример:
useMutation({
mutationFn: updateUser,
onMutate: async () => {
return {
previousUsers,
};
},
onError(error, variables, context) {
console.log(context.previousUsers);
},
});
Важно отдельно типизировать:
TErrorTContextПример:
useMutation<
User,
ApiError,
UpdateUserDto,
MutationContext
>({
mutationFn: updateUser,
});
Порядок generic-параметров:
| Generic | Назначение |
|---|---|
TData |
успешный результат |
TError |
ошибка |
TVariables |
аргументы |
TContext |
optimistic context |
При наследовании Error иногда теряется prototype
chain.
Надёжный вариант:
class ApiError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(
this,
ApiError.prototype
);
}
}
Это особенно важно для старых окружений и некоторых transpiler-конфигураций.
Объекты Error плохо сериализуются:
JSON.stringify(new Error('Ошибка'));
Результат:
{}
Поэтому полезно реализовать:
class ApiError extends Error {
status: number;
toJSON() {
return {
message: this.message,
status: this.status,
};
}
}
Laravel часто возвращает:
{
"message": "The given data was invalid.",
"errors": {
"email": [
"Поле email обязательно"
]
}
}
Тип:
type LaravelValidationError = {
message: string;
errors: Record<string, string[]>;
};
Типичный формат:
{
"detail": "Authentication credentials were not provided."
}
Тип:
type DjangoError = {
detail: string;
};
Крупные проекты часто приводят разные ответы к единому виду:
type ApiError = {
message: string;
code?: string;
fields?: Record<string, string[]>;
};
Это сильно упрощает UI-слой.
Обычно крупные приложения используют следующую схему:
ApiErrorТакой подход делает TanStack Query полностью предсказуемым с точки зрения обработки исключений и существенно снижает количество runtime-ошибок в приложении.