Обработка ошибок с типами

RTK Query предоставляет встроенную систему обработки ошибок, тесно связанную с TypeScript-типами. В отличие от ручной работы с fetch, библиотека формирует предсказуемую структуру ошибок, которую можно безопасно анализировать как в компонентах, так и внутри baseQuery, middleware и lifecycle-обработчиков.

Основные типы ошибок в RTK Query:

  • ошибки HTTP-запросов;
  • сетевые ошибки;
  • ошибки сериализации;
  • кастомные ошибки;
  • ошибки трансформации данных;
  • ошибки внутри queryFn.

Корректная типизация позволяет:

  • безопасно извлекать status;
  • проверять структуру data;
  • различать сетевые и серверные ошибки;
  • создавать универсальные error-handler’ы;
  • исключать any;
  • строить централизованную инфраструктуру обработки ошибок.

Базовая структура ошибки FetchBaseQuery

При использовании fetchBaseQuery RTK Query возвращает ошибки типа:

FetchBaseQueryError

Импорт:

import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'

Структура:

type FetchBaseQueryError =
  | {
      status: number
      data: unknown
    }
  | {
      status: 'FETCH_ERROR'
      data?: undefined
      error: string
    }
  | {
      status: 'PARSING_ERROR'
      originalStatus: number
      data: string
      error: string
    }
  | {
      status: 'TIMEOUT_ERROR'
      error: string
    }
  | {
      status: 'CUSTOM_ERROR'
      data?: unknown
      error: string
    }

RTK Query использует discriminated unions, поэтому TypeScript умеет автоматически сужать типы через проверку status.


Ошибки HTTP-ответов

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

{
  status: 404,
  data: {
    message: 'User not found'
  }
}

Пример endpoint:

const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api'
  }),
  endpoints: (builder) => ({
    getUser: builder.query<User, number>({
      query: (id) => `/users/${id}`
    })
  })
})

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

const { error } = api.useGetUserQuery(1)

Тип:

error: FetchBaseQueryError | SerializedError | undefined

Проверка ошибок через type narrowing

Без narrowing TypeScript не позволит безопасно читать свойства.

Неправильно:

if (error) {
  console.log(error.status)
}

Ошибка:

Property 'status' does not exist

Причина — SerializedError.

Правильно:

if (error && 'status' in error) {
  console.log(error.status)
}

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

error: FetchBaseQueryError

Работа с SerializedError

RTK Query может возвращать:

SerializedError

Импорт:

import type { SerializedError } from '@reduxjs/toolkit'

Структура:

interface SerializedError {
  name?: string
  message?: string
  stack?: string
  code?: string
}

Такие ошибки появляются:

  • при внутренних исключениях;
  • при ошибках thunk;
  • при ошибках runtime;
  • при throw new Error().

Универсальный type guard

Практически всегда создают helper:

import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'

export function isFetchBaseQueryError(
  error: unknown
): error is FetchBaseQueryError {
  return typeof error === 'object' &&
    error != null &&
    'status' in error
}

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

if (isFetchBaseQueryError(error)) {
  console.log(error.status)
}

Проверка SerializedError

Дополнительный guard:

import type { SerializedError } from '@reduxjs/toolkit'

export function isSerializedError(
  error: unknown
): error is SerializedError {
  return typeof error === 'object' &&
    error != null &&
    'message' in error
}

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

if (isSerializedError(error)) {
  console.log(error.message)
}

Полноценная обработка всех вариантов ошибок

Типичный production-подход:

if (error) {
  if (isFetchBaseQueryError(error)) {
    if (typeof error.status === 'number') {
      console.log('HTTP Error', error.status)
    } else {
      console.log('RTKQ Error', error.status)
    }
  } else if (isSerializedError(error)) {
    console.log(error.message)
  }
}

Типизация data внутри ошибки

По умолчанию:

data: unknown

Это сделано намеренно, потому что сервер может вернуть любую структуру.

Типичный API error response:

interface ApiError {
  message: string
  errors?: Record<string, string[]>
}

Извлечение:

if (
  isFetchBaseQueryError(error) &&
  typeof error.status === 'number'
) {
  const data = error.data as ApiError

  console.log(data.message)
}

Создание безопасного extractor

Повторяющиеся приведения типов обычно выносят:

interface ApiError {
  message: string
}

function getErrorMessage(error: unknown): string {
  if (isFetchBaseQueryError(error)) {
    const data = error.data as ApiError

    return data.message
  }

  if (isSerializedError(error)) {
    return error.message ?? 'Unknown error'
  }

  return 'Unknown error'
}

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

const message = getErrorMessage(error)

Типизация кастомного baseQuery

Одна из главных возможностей RTK Query — создание собственного baseQuery.

Пример:

type CustomError = {
  status: number
  message: string
}

const customBaseQuery: BaseQueryFn<
  string,
  unknown,
  CustomError
> = async (url) => {
  try {
    const response = await fetch(url)

    if (!response.ok) {
      return {
        error: {
          status: response.status,
          message: 'Request failed'
        }
      }
    }

    const data = await response.json()

    return { data }
  } catch {
    return {
      error: {
        status: 500,
        message: 'Network error'
      }
    }
  }
}

Третий generic параметр:

BaseQueryFn<
  Args,
  Result,
  Error
>

Именно он определяет тип ошибки.


Типизированные ошибки endpoint’ов

После указания кастомной ошибки:

const api = createApi({
  reducerPath: 'api',
  baseQuery: customBaseQuery,
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts'
    })
  })
})

В компоненте:

const { error } = api.useGetPostsQuery()

Тип:

error: CustomError | undefined

Это полностью исключает необходимость в FetchBaseQueryError.


Типизация queryFn

queryFn позволяет вручную реализовывать запросы.

Пример:

getUser: builder.query<User, number>({
  async queryFn(id) {
    try {
      const response = await fetch(`/users/${id}`)

      if (!response.ok) {
        return {
          error: {
            status: response.status,
            message: 'User error'
          }
        }
      }

      const data = await response.json()

      return { data }
    } catch {
      return {
        error: {
          status: 500,
          message: 'Network error'
        }
      }
    }
  }
})

RTK Query автоматически выводит тип ошибки из baseQuery.


Типизация rejectWithValue

При использовании async thunk совместно с RTK Query важно правильно типизировать reject values.

Пример:

interface ValidationError {
  message: string
  fields: Record<string, string>
}

Thunk:

createAsyncThunk<
  User,
  UserInput,
  {
    rejectValue: ValidationError
  }
>(
  'users/create',
  async (data, { rejectWithValue }) => {
    const response = await fetch('/users', {
      method: 'POST',
      body: JSON.stringify(data)
    })

    if (!response.ok) {
      return rejectWithValue(await response.json())
    }

    return response.json()
  }
)

Теперь:

action.payload

будет иметь тип:

ValidationError

Ошибки unwrap()

RTK Query поддерживает:

.unwrap()

Пример:

try {
  const result = await createUser(data).unwrap()
} catch (error) {
  console.log(error)
}

Тип ошибки зависит от baseQuery.

При fetchBaseQuery:

FetchBaseQueryError | SerializedError

Типизированный unwrap helper

Можно создать helper:

async function safeRequest<T>(
  promise: Promise<T>
): Promise<[T | null, unknown]> {
  try {
    const data = await promise

    return [data, null]
  } catch (error) {
    return [null, error]
  }
}

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

const [result, error] = await safeRequest(
  createUser(data).unwrap()
)

Типизация ошибок трансформации

Ошибки могут возникать внутри:

transformResponse

Пример:

transformResponse: (response: RawUser): User => {
  if (!response.id) {
    throw new Error('Invalid user')
  }

  return {
    id: response.id,
    name: response.name
  }
}

Такие ошибки превращаются в:

SerializedError

Типизация validateStatus

RTK Query позволяет переопределять успешные статусы.

Пример:

query: () => ({
  url: '/login',
  validateStatus: (response, body) => {
    return response.status === 200 && !body.error
  }
})

Если функция возвращает false, RTK Query формирует ошибку.


Обработка FETCH_ERROR

Ошибка сети:

{
  status: 'FETCH_ERROR',
  error: 'TypeError: Failed to fetch'
}

Проверка:

if (
  isFetchBaseQueryError(error) &&
  error.status === 'FETCH_ERROR'
) {
  console.log(error.error)
}

Обработка PARSING_ERROR

Возникает при ошибке JSON parsing.

Пример:

if (
  isFetchBaseQueryError(error) &&
  error.status === 'PARSING_ERROR'
) {
  console.log(error.originalStatus)
  console.log(error.data)
}

Обработка TIMEOUT_ERROR

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

fetchBaseQuery({
  baseUrl: '/api',
  timeout: 5000
})

Возможная ошибка:

{
  status: 'TIMEOUT_ERROR',
  error: 'Timed out'
}

Проверка:

if (
  isFetchBaseQueryError(error) &&
  error.status === 'TIMEOUT_ERROR'
) {
  console.log('Request timeout')
}

CUSTOM_ERROR

Для ручных ошибок:

return {
  error: {
    status: 'CUSTOM_ERROR',
    error: 'Token expired'
  }
}

Проверка:

if (
  isFetchBaseQueryError(error) &&
  error.status === 'CUSTOM_ERROR'
) {
  console.log(error.error)
}

Централизованный error mapper

Часто создают общий mapper:

export function mapError(error: unknown): string {
  if (isFetchBaseQueryError(error)) {
    if (typeof error.status === 'number') {
      const data = error.data as ApiError

      return data.message
    }

    switch (error.status) {
      case 'FETCH_ERROR':
        return 'Network error'

      case 'PARSING_ERROR':
        return 'Response parsing error'

      case 'TIMEOUT_ERROR':
        return 'Request timeout'

      case 'CUSTOM_ERROR':
        return error.error

      default:
        return 'Unknown error'
    }
  }

  if (isSerializedError(error)) {
    return error.message ?? 'Runtime error'
  }

  return 'Unknown error'
}

Типизация middleware обработки ошибок

RTK Query генерирует rejected actions.

Пример middleware:

import { isRejectedWithValue } from '@reduxjs/toolkit'

export const errorMiddleware =
  () => (next) => (action) => {
    if (isRejectedWithValue(action)) {
      console.log(action.payload)
    }

    return next(action)
  }

Тип payload зависит от reject value.


Обработка ошибок через listenerMiddleware

Пример:

listenerMiddleware.startListening({
  matcher: api.endpoints.login.matchRejected,
  effect: async (action) => {
    console.log(action.payload)
  }
})

RTK Query предоставляет типизированные matcher’ы.


Типизация matchRejected

Каждый endpoint имеет:

matchPending
matchFulfilled
matchRejected

Пример:

if (api.endpoints.login.matchRejected(action)) {
  console.log(action.payload)
}

TypeScript автоматически сузит тип action.


Ошибки optimistic update

Ошибки особенно важны при optimistic update.

Пример:

async onQueryStarted(arg, { dispatch, queryFulfilled }) {
  const patchResult = dispatch(
    api.util.updateQueryData(
      'getPosts',
      undefined,
      (draft) => {
        draft.push(arg)
      }
    )
  )

  try {
    await queryFulfilled
  } catch {
    patchResult.undo()
  }
}

queryFulfilled может выбросить:

{
  error: FetchBaseQueryError | SerializedError
}

Типизация queryFulfilled

Можно явно типизировать:

try {
  const { data } = await queryFulfilled
} catch (error) {
  console.log(error)
}

Либо:

try {
  await queryFulfilled
} catch (error: unknown) {
  if (isFetchBaseQueryError(error)) {
    console.log(error.status)
  }
}

Нормализация ошибок API

Многие проекты создают единый контракт ошибок:

interface ApiErrorResponse {
  success: false
  message: string
  code: string
}

Тогда любой endpoint возвращает одинаковую структуру.

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

  • единая типизация;
  • универсальные handlers;
  • единый UI;
  • предсказуемый error parsing;
  • отсутствие хаотичных as.

Generic error extractor

Продвинутый вариант:

function extractError<T>(
  error: unknown
): T | null {
  if (
    isFetchBaseQueryError(error) &&
    typeof error.status === 'number'
  ) {
    return error.data as T
  }

  return null
}

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

const apiError = extractError<ApiError>(error)

Ошибки и unknown

Современный TypeScript трактует catch как unknown.

Правильно:

catch (error: unknown) {
  if (isFetchBaseQueryError(error)) {
    console.log(error.status)
  }
}

Неправильно:

catch (error: any)

any уничтожает типовую безопасность.


Типизация helper-функций обработки ошибок

Пример:

type AppError =
  | FetchBaseQueryError
  | SerializedError

Helper:

function logError(error: AppError) {
  if ('status' in error) {
    console.log(error.status)
  } else {
    console.log(error.message)
  }
}

Расширение FetchBaseQueryError

Иногда создают собственный union:

type ExtendedError =
  | FetchBaseQueryError
  | {
      status: 'VALIDATION_ERROR'
      fields: Record<string, string>
    }

Проверка:

if (error.status === 'VALIDATION_ERROR') {
  console.log(error.fields)
}

Полностью типизированная инфраструктура ошибок

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

  • custom baseQuery;
  • global error mapper;
  • typed error guards;
  • normalized backend responses;
  • centralized toast handlers;
  • listener middleware;
  • typed optimistic rollback;
  • generic extractors;
  • unified API contracts.

Такой подход позволяет RTK Query превращать обработку ошибок из набора if-проверок в полноценную типобезопасную архитектуру взаимодействия с сервером.