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

Любой сетевой запрос может завершиться ошибкой. Сервер может вернуть код 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);
}

Такой подход предотвращает огромное количество ошибок рантайма.


Базовая типизация через generics

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

Полная сигнатура generics

Современный 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

Типизация запроса бесполезна, если 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
  • единый формат ошибок
  • удобная сериализация
  • поддержка inheritance
  • безопасная типизация

Axios и типизация ошибок

При использовании 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

Проверка через isAxiosError

Безопасный способ проверки:

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-параметра.


Преимущества global error typing

Единый контракт

Все запросы используют одинаковую структуру ошибок.

Упрощение кода

Не требуется писать:

useQuery<Data, ApiError>()

в каждом запросе.

Централизация обработки

Можно построить общий механизм уведомлений:

toast.error(error.message);

без постоянных проверок типов.


Универсальная структура ApiError

Часто создают единый тип:

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

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


Типизация onError

Пример:

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 получает ошибку:

retry(failureCount, error)

Пример:

useQuery<User[], ApiError>({
    queryKey: ['users'],
    queryFn: fetchUsers,

    retry(failureCount, error) {
        if (error.status === 404) {
            return false;
        }

        return failureCount < 3;
    },
});

throwOnError и типизация

Опция:

throwOnError: true

заставляет TanStack Query пробрасывать ошибку в ErrorBoundary.

Пример:

useQuery<User[], ApiError>({
    queryKey: ['users'],
    queryFn: fetchUsers,
    throwOnError: true,
});

ErrorBoundary и неизвестные ошибки

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 API

Нативный 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

GraphQL-серверы часто возвращают:

{
    "errors": [
        {
            "message": "Unauthorized"
        }
    ]
}

Удобно создать отдельный тип:

type GraphQLError = {
    message: string;
    path?: string[];
};

И общий контейнер:

type GraphQLResponseError = {
    errors: GraphQLError[];
};

Union-типы ошибок

Иногда 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);
}

Discriminated unions

Лучший вариант union-типов — discriminated unions.

Пример:

type ApiError =
    | {
          type: 'network';
          message: string;
      }
    | {
          type: 'validation';
          fields: Record<string, string>;
      };

TypeScript автоматически сужает тип:

if (error.type === 'validation') {
    error.fields;
}

Обработка unknown в catch

Начиная с TypeScript 4.4:

catch (error)

получает тип unknown.

Поэтому нужен type narrowing:

catch (error) {
    if (error instanceof Error) {
        console.log(error.message);
    }
}

Type guards для ошибок

Можно создавать собственные 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);
}

Типизация глобального QueryCache

Можно обрабатывать ошибки централизованно.

Пример:

const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError(error) {
            console.log(error);
        },
    }),
});

Тип error обычно:

unknown

Поэтому желательно использовать нормализацию:

onError(error) {
    const normalized = normalizeError(error);

    toast.error(normalized.message);
}

Типизация MutationCache

Аналогично:

const queryClient = new QueryClient({
    mutationCache: new MutationCache({
        onError(error) {
            console.log(error);
        },
    }),
});

Использование Zod для ошибок

При валидации серверных ответов удобно использовать Zod.

Пример:

const ErrorSchema = z.object({
    message: z.string(),
    status: z.number(),
});

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

const parsed = ErrorSchema.parse(data);

Runtime validation ошибок

TypeScript проверяет типы только во время компиляции.

Сервер может вернуть:

{
    "msg": "Ошибка"
}

вместо:

{
    "message": "Ошибка"
}

Поэтому runtime validation особенно важна для ошибок API.


Типизация ошибок infinite queries

useInfiniteQuery также поддерживает TError.

Пример:

useInfiniteQuery<
    PostsResponse,
    ApiError
>({
    queryKey: ['posts'],
    queryFn: fetchPosts,
});

Ошибки optimistic updates

Во время optimistic update ошибка часто содержит rollback context.

Пример:

useMutation({
    mutationFn: updateUser,

    onMutate: async () => {
        return {
            previousUsers,
        };
    },

    onError(error, variables, context) {
        console.log(context.previousUsers);
    },
});

Важно отдельно типизировать:

  • TError
  • TContext

Полная типизация useMutation

Пример:

useMutation<
    User,
    ApiError,
    UpdateUserDto,
    MutationContext
>({
    mutationFn: updateUser,
});

Порядок generic-параметров:

Generic Назначение
TData успешный результат
TError ошибка
TVariables аргументы
TContext optimistic context

Проблема Error inheritance в Javascript

При наследовании 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

Laravel часто возвращает:

{
    "message": "The given data was invalid.",
    "errors": {
        "email": [
            "Поле email обязательно"
        ]
    }
}

Тип:

type LaravelValidationError = {
    message: string;

    errors: Record<string, string[]>;
};

Типизация ошибок Django REST

Типичный формат:

{
    "detail": "Authentication credentials were not provided."
}

Тип:

type DjangoError = {
    detail: string;
};

Унификация разных серверных форматов

Крупные проекты часто приводят разные ответы к единому виду:

type ApiError = {
    message: string;
    code?: string;
    fields?: Record<string, string[]>;
};

Это сильно упрощает UI-слой.


Рекомендуемая архитектура

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

  1. HTTP-клиент всегда выбрасывает ApiError
  2. Все ошибки проходят нормализацию
  3. Query и Mutation используют global error typing
  4. UI работает только с единым типом ошибок
  5. Runtime validation проверяет серверные ответы
  6. ErrorBoundary получает уже нормализованные ошибки

Такой подход делает TanStack Query полностью предсказуемым с точки зрения обработки исключений и существенно снижает количество runtime-ошибок в приложении.