Перейти к основному содержимому

Классификация ошибок

Проект обязан использовать единую классификацию ошибок. Это обеспечивает одинаковое поведение UI и предсказуемость для пользователя. Все ошибки нормализуются в единый формат перед отображением в интерфейсе.


Категории ошибок

MUST

Проект обязан поддерживать следующие категории ошибок:

  • Network/Connectivity — отсутствие сети, таймауты, сетевые сбои
  • Auth/Access — 401 (unauthorized), 403 (forbidden), истекшая сессия, отсутствие прав доступа
  • Not Found — 404 (ресурс не найден), отсутствующие данные
  • Conflict — 409 (конфликт версий), повторная операция, race condition
  • Other — прочие и доменно-специфичные ошибки

SHOULD

  • Validation — можно выделить отдельно от Other для ошибок валидации форм
  • Domain / Business Rules — ошибки, специфичные для домена приложения: нарушены бизнес-правила, ограничения или состояния данных.
    • Примеры: закончилась подписка, превышен лимит запросов на тарифе, аккаунт не верифицирован, операция запрещена по бизнес-правилу, истёк пробный период.

Область применения

Классификация применяется для:

  • Back-end API endpoints
  • Next.js API routes
  • GraphQL queries/mutations
  • Внешние сервисы и интеграции

Единая модель ошибки

MUST

  • Ошибка нормализуется в единый формат — перед передачей в UI компонент.
  • UI работает только с нормализованной ошибкой — raw ошибки не попадают в компоненты.
  • Модель содержит все необходимые данные — тип, сообщение, статус, возможность retry.

Пример модели

export enum ErrorKind {
Network = 'network',
Auth = 'auth',
NotFound = 'not_found',
Conflict = 'conflict',
Validation = 'validation',
Server = 'server',
Unknown = 'unknown',
}

export interface AppError {
kind: ErrorKind; // Категория ошибки
messageKey: string; // Ключ для i18n локализации
status?: number; // HTTP статус (если применимо)
retriable?: boolean; // Можно ли повторить операцию
context?: Record<string, unknown>; // Дополнительный контекст
}

Пример нормализации

import { err, Result, ResultAsync } from 'neverthrow'; // или аналог из ts-results
import { match } from 'ts-pattern';

interface HttpError {
status: number;
}

const isHttpError = (e: unknown): e is HttpError =>
typeof e === 'object' &&
e !== null &&
typeof (e as HttpError).status === 'number';

const httpError = (
kind: ErrorKind,
messageKey: string,
status: number,
retriable = false
): AppError => ({ kind, messageKey, status, retriable });

export function normalizeError(error: unknown): Result {
return err(
match(error)
.when(isHttpError, ({ status }) =>
match(status)
.when(
(s) =>
s === HTTP_STATUS.Unauthorized || s === HTTP_STATUS.Forbidden,
(s) => httpError(ErrorKind.Auth, 'errors.auth', s)
)
.when(
(s) => s === HTTP_STATUS.NotFound,
(s) => httpError(ErrorKind.NotFound, 'errors.not_found', s)
)
.when(
(s) => s === HTTP_STATUS.Conflict,
(s) => httpError(ErrorKind.Conflict, 'errors.conflict', s)
)
.when(
(s) => s === HTTP_STATUS.UnprocessableEntity,
(s) => httpError(ErrorKind.Validation, 'errors.validation', s)
)
.when(
(s) => s >= HTTP_STATUS.InternalServerError,
(s) => httpError(ErrorKind.Server, 'errors.server', s, true)
)
.otherwise((s) => httpError(ErrorKind.Unknown, 'errors.unknown', s))
)
// TypeError (fetch failed, сеть недоступна), runtime exceptions, неизвестные форматы
.otherwise(
(): AppError => ({
kind: ErrorKind.Network,
messageKey: 'errors.network',
retriable: true,
})
)
);
}

normalizeError встраивается в async-цепочку через fromApiCall — единая точка перехвата throw без разрозненных try/catch:

const fromApiCall = (promise: Promise): ResultAsync =>
ResultAsync.fromPromise(promise, normalizeError);

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

// ✅ — ошибка явна на каждом шаге, тип гарантирован
const result = await fromApiCall(fetchUser(userId));

if (result.isOk()) {
renderProfile(result.value);
} else {
renderError(result.error); // всегда AppError, никаких raw
}
// ✅ — Railway-цепочка без вложенных try/catch
const result = await fromApiCall(fetchUser(userId)).andThen((user) =>
fromApiCall(fetchUserProfile(user.id))
);

result.match({
ok: (profile) => renderProfile(profile),
err: (appError) => renderError(appError),
});
// ❌ — неконтролируемый throw, тип ошибки неизвестен
try {
const user = await fetchUser(userId);
renderProfile(user);
} catch (e) {
toast.error(e.message); // FORBIDDEN: raw ошибка в UI
}

Паттерн Result

Функции, взаимодействующие с API или выполняющие операции с возможной ошибкой, возвращают Result вместо throw. Это делает поток ошибок явным и предсказуемым на всём пути от API до UI.

Result<T, E> = Ok<T> | Err<E>

Ok<T> — успешный результат с данными, Err<AppError> — нормализованная ошибка. Компонент всегда знает, с каким из двух состояний работает.

MUST

  • Функция с возможной ошибкой возвращает Result<T, AppError> — вместо throw / неконтролируемого catch.

SHOULD

Выбор инструмента зависит от сложности сценария:

  • neverthrow — рекомендуется как основная библиотека. Богатый API: .andThen(), .map(), .mapErr(), ResultAsync для async-цепочек без try/catch.

  • ts-results — альтернатива с похожим API, допустима при наличии в проекте.

  • Discriminated Union — для простых сценариев с 2–3 состояниями, где библиотека избыточна:

    type Result<T> = { ok: true; data: T } | { ok: false; error: AppError };

HTTP статусы

MUST

  • В проекте существует единый enum или константа со статусами — избегаем magic numbers.
  • Используется единообразное именование — например, HTTP_STATUS или HttpStatusCode.

Пример

export const HTTP_STATUS = {
// 2xx Success
Ok: 200,
Created: 201,
NoContent: 204,

// 4xx Client Errors
BadRequest: 400,
Unauthorized: 401,
Forbidden: 403,
NotFound: 404,
Conflict: 409,
UnprocessableEntity: 422,
TooManyRequests: 429,

// 5xx Server Errors
InternalServerError: 500,
BadGateway: 502,
ServiceUnavailable: 503,
GatewayTimeout: 504,
} as const;

export type HttpStatus = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS];

SHOULD

  • Группировать статусы по категориям — для удобства использования и проверок:
export const isHttpClientError = (status: number): boolean => {
return status >= 400 && status < 500;
};

export const isHttpServerError = (status: number): boolean => {
return status >= 500 && status < 600;
};

Запрещённые практики

FORBIDDEN

  • Передавать raw ошибки напрямую в UI — всегда нормализуем через normalizeError.
  • Показывать пользователю error.message из back-end — только локализованные ключи.
  • Использовать magic numbers — только именованные константы для HTTP статусов.
  • Игнорировать категорию ошибки — каждая ошибка должна быть классифицирована.

❌ Плохо

// Прямая передача raw ошибки
catch (error) {
toast.error(error.message); // FORBIDDEN
}

// Magic numbers
if (response.status === 401) { // FORBIDDEN
// ...
}

✅ Хорошо

// Нормализация и использование типизированной ошибки
catch (rawError) {
const error = normalizeError(rawError);
toast.error(t(error.messageKey));
}

// Именованные константы
if (response.status === HTTP_STATUS.Unauthorized) {
// ...
}