Коротко: Разбираем 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 теряет эти данные.
Ожидаемые доменные ошибки против фатальных аварий
В архитектуре приложения критично разделять два класса нештатных ситуаций:
- Ожидаемые сбои (Expected Failures): валидация DTO, отсутствие прав доступа (403), истекший токен (401), ресурс не найден (404). Это не баги — это нормальные состояния доменной модели, которые интерфейс обязан обработать штатно.
- Фатальные аварии (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 переводит обработку ошибок из области неявных соглашений в строго типизированный контракт.
Моделирование ошибок как данных дает кодовой базе фронтенда три ключевых преимущества:
- Предсказуемость: сигнатуры функций честно декларируют все возможные исходы.
- Безопасный рефакторинг: компилятор TypeScript подсказывает места, где добавленный тип ошибки еще не был обработан.
- Устранение runtime-сбоев: разработчик физически не может обратиться к данным ответа API, пока не обработает возможный сбой.
Результат — чистый, самодокументированный код сетевого слоя и устойчивое к любым ответам сервера клиентское приложение.




.svg.webp)





