Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Обработка ошибок API через Result Pattern в TypeScript: как отказаться от try/catch и повысить надежность кода

Тимофей Ищенко

Коротко: Разбираем Result Pattern в TypeScript: как отказаться от try/catch при работе с API, типизировать ошибки через Discriminated Unions и повысить надежность кода.

Механизм исключений через throw new Error() и блоки try/catch исторически создавался для аварийных ситуаций: сбоев оборудования, нехватки памяти или синтаксических ошибок в рантайме. Однако во фронтенд-разработке этим инструментом часто пытаются управлять рядовым бизнес-потоком — от сетевых кодов 404 и 422 до невалидных полей формы.

Главная проблема такого подхода в TypeScript — потеря типизации. Сигнатура async function fetchUser(): Promise<User> скрывает факт возможной ошибки. При этом в блоке catch (err) переменная err неизбежно имеет тип unknown или any. Компилятор перестает защищать кодовую базу, превращая обработку граничных сценариев в работу вслепую.

Решение этой проблемы — Result Pattern (моделирование ошибок как значений). Подход пришел из языков со строгой типизацией (Rust, Go, Haskell) и позволяет сделать ошибку равноправным результатом выполнения функции, заставляя TypeScript контролировать каждый возможный сценарий еще на этапе сборки.


Анатомия проблемы: почему throw и try/catch вредят фронтенд-архитектуре

Стандартный императивный подход к обработке сетевых запросов строит архитектуру на неявных допущениях.

// Сигнатура скрывает сетевые сбои и 4xx/5xx ответы
async function getUserProfile(userId: string): Promise<UserProfile> {
  const response = await fetch(`/api/users/${userId}`);
  
  if (!response.ok) {
    throw new Error(`Failed to load profile: ${response.status}`);
  }
  
  return response.json();
}

Разработчик, вызывающий getUserProfile, опирается только на тип возвращаемого значения Promise<UserProfile>. Нигде в контракте функции не зафиксировано, что она может прервать поток выполнения.

Потеря контекста типов в блоке catch

Начиная с TypeScript 4.0, при включенном флаге useUnknownInCatchVariables тип ошибки в catch равен unknown. Это защищает от опасного any, но порождает boilerplate и небезопасные приведения типов (as):

try {
  const profile = await getUserProfile('123');
  renderProfile(profile);
} catch (err: unknown) {
  // TypeScript не знает структуры err
  // Приходится писать ручные type guards или делать слепой каст
  if (err instanceof Error) {
    showNotification(err.message);
  }
}

Если бэкенд возвращает структурированный JSON с кодом доменной ошибки (например, { code: "USER_BLOCKED", retryAfter: 300 }), приведение через instanceof Error теряет эти данные.

Ожидаемые доменные ошибки против фатальных аварий

В архитектуре приложения критично разделять два класса нештатных ситуаций:

  1. Ожидаемые сбои (Expected Failures): валидация DTO, отсутствие прав доступа (403), истекший токен (401), ресурс не найден (404). Это не баги — это нормальные состояния доменной модели, которые интерфейс обязан обработать штатно.
  2. Фатальные аварии (Panics / Unhandled Exceptions): TypeError: Cannot read properties of undefined, повреждение памяти, синтаксический сбой в стороннем бандле. Это непредвиденные системные сбои.

Использование throw для ожидаемых ошибок заставляет механизм исключений управлять нормальным ходом бизнес-логики, ломая локальность рассуждений о коде.


Что такое Result Pattern: фундаментальные концепции

Паттерн Result трансформирует подход: функция всегда возвращает значение, представляющее собой либо успешное выполнение, либо типизированную ошибку.

       ┌─────────────┐
       │   Запрос    │
       └──────┬──────┘
              │
       ┌──────┴──────┐
       │   Result    │
       └──────┬──────┘
       ───────┴───────
      │               │
┌─────┴─────┐   ┌─────┴─────┐
│    Ok     │   │    Err    │
│  (Data)   │   │  (Error)  │
└───────────┘   └───────────┘

В TypeScript паттерн идеально реализуется с помощью дискриминированных объединений (Discriminated Unions). Поле-дискриминант (например, флаг ok) позволяет компилятору однозначно сужать типы при проверке условий.

Реализация легковесного типа Result

Для внедрения паттерна не требуются тяжелые библиотеки вроде fp-ts. Достаточно лаконичной абстракции:

export type Ok<T> = {
  readonly ok: true;
  readonly value: T;
};

export type Err<E> = {
  readonly ok: false;
  readonly error: E;
};

export type Result<T, E> = Ok<T> | Err<E>;

// Вспомогательные фабричные функции
export const ok = <T>(value: T): Ok<T> => ({
  ok: true,
  value,
});

export const err = <E>(error: E): Err<E> => ({
  ok: false,
  error,
});

Когда функция возвращает Result<UserProfile, NetworkError | NotFoundError>, TypeScript не позволит обратиться к value, пока не будет проверен флаг ok.


Пошаговая реализация Result Pattern для сетевых запросов

Спроектируем типобезопасный сетевой слой для фронтенда, трансформирующий статусы HTTP и сетевые сбои в типизированные доменные объединения.

Шаг 1: Описание типов ошибок API

Создадим иерархию ошибок сетевого взаимодействия:

export type NetworkError = {
  readonly type: 'NETWORK_ERROR';
  readonly message: string;
};

export type HttpError = {
  readonly type: 'HTTP_ERROR';
  readonly status: number;
  readonly payload?: unknown;
};

export type ValidationError = {
  readonly type: 'VALIDATION_ERROR';
  readonly issues: string[];
};

export type ApiClientError = NetworkError | HttpError | ValidationError;

Шаг 2: Безопасный HTTP-клиент

Обернем низкоуровневый fetch в функцию, которая перехватывает сетевые сбои браузера внутри себя и возвращает Result:

export async function safeFetch<T>(
  url: string,
  options?: RequestInit
): Promise<Result<T, ApiClientError>> {
  let response: Response;

  try {
    response = await fetch(url, options);
  } catch (e: unknown) {
    return err({
      type: 'NETWORK_ERROR',
      message: e instanceof Error ? e.message : 'Unknown network failure',
    });
  }

  if (!response.ok) {
    let payload: unknown;
    try {
      payload = await response.json();
    } catch {
      payload = undefined;
    }

    return err({
      type: 'HTTP_ERROR',
      status: response.status,
      payload,
    });
  }

  try {
    const data = (await response.json()) as T;
    return ok(data);
  } catch {
    return err({
      type: 'VALIDATION_ERROR',
      issues: ['Malformed JSON response from server'],
    });
  }
}

Шаг 3: Валидация схемы через Zod

В реальных приложениях доверять типизации с бэкенда нельзя. Интегрируем схему парсинга данных:

import { z } from 'zod';

export const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user']),
});

export type User = z.infer<typeof UserSchema>;

export async function fetchUserById(
  id: string
): Promise<Result<User, ApiClientError>> {
  const fetchResult = await safeFetch<unknown>(`/api/users/${id}`);

  if (!fetchResult.ok) {
    return fetchResult; // Пробрасываем ошибку наверх
  }

  const parseResult = UserSchema.safeParse(fetchResult.value);

  if (!parseResult.success) {
    return err({
      type: 'VALIDATION_ERROR',
      issues: parseResult.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
    });
  }

  return ok(parseResult.data);
}

Применение Result Pattern в React-приложении

В UI-слое Result позволяет строить декларативный и предсказуемый рендеринг без размазанных по коду try/catch.

Паттерн Exhaustiveness Checking (Исчерпывающая проверка)

Используя оператор switch и тип never, мы гарантируем, что интерфейс обрабатывает все возможные типы ошибок. Если бэкенд или клиент добавит новый тип сбоя, проект не скомпилируется:

function assertNever(x: never): never {
  throw new Error(`Unhandled union member: ${JSON.stringify(x)}`);
}

export function formatErrorMessage(error: ApiClientError): string {
  switch (error.type) {
    case 'NETWORK_ERROR':
      return 'Отсутствует интернет-соединение. Проверьте сеть.';
    case 'HTTP_ERROR':
      if (error.status === 404) return 'Пользователь не найден.';
      if (error.status === 403) return 'Недостаточно прав для просмотра.';
      return `Ошибка сервера: ${error.status}`;
    case 'VALIDATION_ERROR':
      return `Некорректный формат данных: ${error.issues.join(', ')}`;
    default:
      return assertNever(error);
  }
}

Использование в кастомных хуках и компонентах

import React, { useState, useEffect } from 'react';

export const UserProfileView: React.FC<{ userId: string }> = ({ userId }) => {
  const [state, setState] = useState<{
    loading: boolean;
    data: User | null;
    error: string | null;
  }>({
    loading: true,
    data: null,
    error: null,
  });

  useEffect(() => {
    let isMounted = true;

    async function load() {
      setState((prev) => ({ ...prev, loading: true }));
      const result = await fetchUserById(userId);

      if (!isMounted) return;

      if (result.ok) {
        // TypeScript знает: здесь доступно только result.value
        setState({ loading: false, data: result.value, error: null });
      } else {
        // TypeScript знает: здесь доступно только result.error
        setState({
          loading: false,
          data: null,
          error: formatErrorMessage(result.error),
        });
      }
    }

    load();

    return () => {
      isMounted = false;
    };
  }, [userId]);

  if (state.loading) return <div>Загрузка профиля...</div>;
  if (state.error) return <div className="error-alert">{state.error}</div>;
  if (!state.data) return null;

  return (
    <div>
      <h2>{state.data.email}</h2>
      <span>Роль: {state.data.role}</span>
    </div>
  );
};

Сравнение подходов: Exceptions vs Result Pattern

Критерий Традиционный try/catch + throw Result Pattern (Result<T, E>)
Сигнатура функции Не отражает риски (Promise<User>) Очевидна (Promise<Result<User, ApiError>>)
Типизация ошибки unknown / any Строгий union (NetworkError \| HttpError)
Контроль компилятора Нет проверки обработки ошибок Не скомпилируется без проверки .ok
Влияние на рефакторинг Высокий риск пропустить try/catch Безопасно, изменения типов отслеживаются
Читаемость потока Разрыв контекста через прерывание стека Линейный поток выполнения (Railway model)

Когда Result Pattern уместен, а когда нужен классический try/catch

Отказ от try/catch не должен быть абсолютным догматизмом. В архитектуре фронтенда у каждого инструмента своя зона ответственности.

       ┌────────────────────────────────────────────────────────┐
       │                Область применения                     │
       └──────────────────────────┬─────────────────────────────┘
                                  │
         ┌────────────────────────┴────────────────────────┐
         ▼                                                 ▼
┌─────────────────────────────┐       ┌─────────────────────────────┐
│       Result Pattern        │       │      Классический catch     │
├─────────────────────────────┤       ├─────────────────────────────┤
│ • Сетевой слой (API)        │       │ • React Error Boundaries    │
│ • Валидация форм (DTO)      │       │ • Сторонние legacy-библиотеки│
│ • Доменная бизнес-логика    │       │ • JSON.parse / LocalStorage │
│ • Парсинг URL/конфигов      │       │ • Критические сбои рантайма │
└─────────────────────────────┘       └─────────────────────────────┘

Где Result Pattern незаменим

  • Сетевой слой: обработка всех ожидаемых кодов (4xx, 5xx) и таймаутов.
  • Бизнес-логика и доменные сервисы: расчеты, переходы по статусам заказа, валидация прав.
  • Парсинг структур данных: валидация через Zod, Yup или собственные схемы.

Где сохраняется try/catch

  • React Error Boundary: компонент верхнего уровня обязан отлавливать непредвиденные сбои рендеринга для предотвращения «белого экрана».
  • Интеграция со сторонними библиотеками: SDK аналитики, устаревшие NPM-пакеты, выбрасывающие исключения наружу.
  • Низкоуровневые API браузера: работа с localStorage.setItem (может выбросить QuotaExceededError), вызовы JSON.parse или WebGL/Canvas API. Внутри таких утилит пишется локальный try/catch, который сразу же конвертирует исключение в Result для остального приложения.

FAQ

1. Не снижает ли Result Pattern производительность из-за создания лишних объектов?

Нет. Создание плоских объектов вида { ok: true, value } в современных JavaScript-движках (V8, JavaScriptCore) оптимизируется до скрытых классов (hidden classes) и практически не расходует ресурсы. Напротив, генерация исключения через throw new Error() требует синхронного сбора и развертывания стека вызовов (stack trace), что значительно более ресурсоемко с точки зрения CPU и памяти.

2. Обязательно ли устанавливать тяжелые внешние библиотеки?

Нет. Достаточно базового типа на дискриминированных объединениях размером в 15 строк кода, как показано в примере выше. Если требуются готовые методы трансформации вроде .map(), .mapErr() или .andThen(), можно подключить ультралегковесные решения (например, neverthrow), вес которых составляет единицы килобайт.

3. Как Result Pattern сочетается с React Error Boundary?

Они дополняют друг друга. Error Boundary изолирует фатальные, непредсказуемые аварии приложения (например, ошибки рендеринга React). Result Pattern обрабатывает штатные, предсказуемые ошибки бизнес-логики и сетевого слоя, не позволяя им доходить до Error Boundary и ломать дерево компонентов.

4. Как обрабатывать цепочки зависимых асинхронных вызовов без лесенки из if (!res.ok)?

Если несколько операций должны выполняться последовательно (вызов API 1 $\rightarrow$ вызов API 2 $\rightarrow$ вызов API 3), в специализированных библиотеках используется метод .andThen(). Без сторонних библиотек достаточно писать линейный guard-код с ранним возвратом (early return):

const userRes = await fetchUser(id);
if (!userRes.ok) return userRes;

const settingsRes = await fetchSettings(userRes.value.settingsId);
if (!settingsRes.ok) return settingsRes;

return ok({ user: userRes.value, settings: settingsRes.value });

5. Можно ли использовать Result Pattern с TanStack Query (React Query)?

Да. По умолчанию TanStack Query переводит запрос в состояние isError, только если промис был отклонен (rejected). Если ваша функция возвращает Promise<Result<T, E>>, промис всегда резолвится успешно. Вы можете либо сохранять Result в поле data и разруливать его в UI, либо выбрасывать ошибку только внутри queryFn, если вам строго необходим стандартный флаг isError:

useQuery({
  queryKey: ['user', id],
  queryFn: async () => {
    const res = await fetchUserById(id);
    if (!res.ok) throw res.error; // Ошибка будет типизирована в onError
    return res.value;
  }
});

6. Как быть с TypeScript-типом Promise.all при использовании Result?

При передаче массива промисов Promise.all([fetchA(), fetchB()]) возвращается массив результатов: [Result<A, ErrA>, Result<B, ErrB>]. Это позволяет независимо обработать каждую операцию: одна может завершиться ошибкой, а вторая — успехом, что невозможно при классическом Promise.all, который мгновенно падает в catch при первом же отклоненном промисе.


Заключение

Отказ от бесконтрольного использования throw и try/catch в пользу Result Pattern переводит обработку ошибок из области неявных соглашений в строго типизированный контракт.

Моделирование ошибок как данных дает кодовой базе фронтенда три ключевых преимущества:

  1. Предсказуемость: сигнатуры функций честно декларируют все возможные исходы.
  2. Безопасный рефакторинг: компилятор TypeScript подсказывает места, где добавленный тип ошибки еще не был обработан.
  3. Устранение runtime-сбоев: разработчик физически не может обратиться к данным ответа API, пока не обработает возможный сбой.

Результат — чистый, самодокументированный код сетевого слоя и устойчивое к любым ответам сервера клиентское приложение.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также