В традиционной разработке на JavaScript и TypeScript доминирует императивная модель обработки исключений через оператор throw и конструкцию try/catch. Однако в enterprise-архитектуре данный подход порождает фундаментальный изъян: нечестные сигнатуры типов (Dishonest Type Signatures).
Рассмотрим классическую функцию:
async function fetchUserProfile(userId: string): Promise<UserProfile> {
// Может выбросить NetworkError, ParseError, UnauthorizedError или NotFoundError
}Сигнатура функции утверждает, что результатом выполнения будет строго Promise<UserProfile>. Информация о потенциальных сбоях полностью скрыта от системы статического анализа TypeScript. Компилятор не обязывает разработчика обрабатывать исключения, а в блоке catch (error: unknown) тип ошибки стирается до unknown, что принуждает к небезопасным приведениям типов.
Функциональная парадигма решает эту проблему за счет превращения ошибок и отсутствия данных в первоклассные значения (Errors as Values):
Maybe): замена непредсказуемым null и undefined.Result): типобезопасная альтернатива механизму исключений throw.Вместо скрытого разрыва стека вызовов через throw вычисления организуются в виде двухполосного конвейера (Railway Oriented Programming):
+-------------------------------------------------------------------------+
| КОНВЕЙЕРНАЯ ОБРАБОТКА ВЫЧИСЛЕНИЙ (EITHER) |
+-------------------------------------------------------------------------+
| Успешная ветка (Right): |
| [Входной JSON] ---> map(parseDTO) ---> flatMap(validate) ---> Right(User)|
| | | |
| v (сбой) v (сбой) |
| Ошибочная ветка (Left): | | |
| [Исключение] ---> Left(SyntaxError) Left(ValidationError) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| ТЕРМИНАЛЬНАЯ РАСПАКОВКА (MATCH / FOLD) |
| match({ |
| onLeft: (error) => renderErrorBanner(error), // Строгая ветка ошибки|
| onRight: (user) => renderUserProfile(user) // Чистый тип User |
| }) |
+-------------------------------------------------------------------------+Любая ошибка переводит конвейер на нижнюю полосу (Left). Последующие вызовы map и flatMap автоматически пропускаются, избавляя код от вложенных проверок if (err) return.
Создать дискриминированное объединение типов ошибок (DomainError) с уникальным строковым тегом code.
Заменить вызовы fetch, JSON.parse, fs.readFileSync на вызовы функций, возвращающих Either<E, A>.
Выстроить линейный конвейер вычислений, передавая результат предыдущего шага в следующий через Either.flatMap.
На границе приложения (HTTP контроллер, UI-компонент) выполнить match для исчерпывающей обработки успехов и сбоев.
Удалить устаревшие блоки перехвата исключений, сделав стек вызовов чистым и предсказуемым.
Какое ключевое архитектурное преимущество дает использование контейнера Either<E, A> вместо императивного оператора throw new Error()?
Option без падений TypeError: Cannot read property of undefined.Реализуем эталонную архитектуру Option и Either на базе тегированных объединений (Discriminated Unions) с полной типизацией:
export interface Some<A> {
readonly _tag: 'Some';
readonly value: A;
}
export interface None {
readonly _tag: 'None';
}
export type Option<A> = Some<A> | None;
export const Option = {
some: <A>(value: A): Option<A> => ({ _tag: 'Some', value }),
none: (): Option<never> => ({ _tag: 'None' }),
fromNullable: <A>(value: A | null | undefined): Option<NonNullable<A>> =>
value !== null && value !== undefined
? Option.some(value as NonNullable<A>)
: Option.none(),
isSome: <A>(fa: Option<A>): fa is Some<A> => fa._tag === 'Some',
isNone: <A>(fa: Option<A>): fa is None => fa._tag === 'None',
map: <A, B>(fa: Option<A>, f: (a: A) => B): Option<B> =>
fa._tag === 'Some' ? Option.some(f(fa.value)) : Option.none(),
flatMap: <A, B>(fa: Option<A>, f: (a: A) => Option<B>): Option<B> =>
fa._tag === 'Some' ? f(fa.value) : Option.none(),
getOrElse: <A>(fa: Option<A>, onNone: () => A): A =>
fa._tag === 'Some' ? fa.value : onNone(),
match: <A, B>(fa: Option<A>, onNone: () => B, onSome: (a: A) => B): B =>
fa._tag === 'Some' ? onSome(fa.value) : onNone(),
};export interface Left<E> {
readonly _tag: 'Left';
readonly left: E;
}
export interface Right<A> {
readonly _tag: 'Right';
readonly right: A;
}
export type Either<E, A> = Left<E> | Right<A>;
export const Either = {
left: <E, A = never>(error: E): Either<E, A> => ({ _tag: 'Left', left: error }),
right: <A, E = never>(value: A): Either<E, A> => ({ _tag: 'Right', right: value }),
isLeft: <E, A>(ma: Either<E, A>): ma is Left<E> => ma._tag === 'Left',
isRight: <E, A>(ma: Either<E, A>): ma is Right<A> => ma._tag === 'Right',
// Безопасный мост от императивных функций с throw к Either
tryCatch: <E, A>(
tryFn: () => A,
onError: (error: unknown) => E
): Either<E, A> => {
try {
return Either.right(tryFn());
} catch (err: unknown) {
return Either.left(onError(err));
}
},
map: <E, A, B>(ma: Either<E, A>, f: (a: A) => B): Either<E, B> =>
ma._tag === 'Right' ? Either.right(f(ma.right)) : Either.left(ma.left),
flatMap: <E, A, B>(ma: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> =>
ma._tag === 'Right' ? f(ma.right) : Either.left(ma.left),
match: <E, A, B>(
ma: Either<E, A>,
onLeft: (error: E) => B,
onRight: (value: A) => B
): B => (ma._tag === 'Left' ? onLeft(ma.left) : onRight(ma.right)),
};Построим типобезопасный конвейер валидации пользовательских данных без единого вызова throw:
// Определение строгого объединения доменных ошибок
export type DomainError =
| { readonly code: 'INVALID_JSON'; readonly raw: string }
| { readonly code: 'VALIDATION_FAILED'; readonly field: string; readonly reason: string }
| { readonly code: 'UNAUTHORIZED'; readonly userId: string };
export interface UserAccount {
readonly id: string;
readonly email: string;
readonly balanceCents: number;
}
// 1. Безопасный парсинг строки
export function parseRawJson(rawText: string): Either<DomainError, unknown> {
return Either.tryCatch(
() => JSON.parse(rawText) as unknown,
() => ({ code: 'INVALID_JSON', raw: rawText })
);
}
// 2. Валидация структуры данных
export function validateAccountDto(input: unknown): Either<DomainError, UserAccount> {
if (typeof input !== 'object' || input === null) {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'root',
reason: 'Payload must be an object',
});
}
const candidate = input as Record<string, unknown>;
if (typeof candidate.id !== 'string' || candidate.id.trim() === '') {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'id',
reason: 'ID must be a non-empty string',
});
}
if (typeof candidate.email !== 'string' || !candidate.email.includes('@')) {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'email',
reason: 'Invalid email address format',
});
}
if (typeof candidate.balanceCents !== 'number' || candidate.balanceCents < 0) {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'balanceCents',
reason: 'Balance must be a non-negative integer',
});
}
return Either.right({
id: candidate.id,
email: candidate.email,
balanceCents: candidate.balanceCents,
});
}
// 3. Композиция пайплайна
export function processRawAccount(rawJson: string): Either<DomainError, UserAccount> {
const parsed = parseRawJson(rawJson);
return Either.flatMap(parsed, validateAccountDto);
}Получение сырых внешних данных (JSON, HTTP Headers, Storage).
Оборачивание небезопасного JSON.parse в Either без утечки неконтролируемых исключений.
При успехе парсинга вызывается валидатор. При ошибке валидатор игнорируется.
Принудительная обработка обоих исходов: генерация HTTP 400 или продолжение бизнес-логики.
Для линейной композиции функций без глубокой вложенности вызовов создадим типобезопасный комбинатор pipe:
export function pipe<A>(a: A): A;
export function pipe<A, B>(a: A, ab: (a: A) => B): B;
export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
export function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D;
export function pipe<A, B, C, D, E>(
a: A,
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E
): E;
export function pipe<A, B, C, D, E, F>(
a: A,
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
ef: (e: E) => F
): F;
export function pipe(
value: unknown,
...fns: ReadonlyArray<(arg: unknown) => unknown>
): unknown {
return fns.reduce((acc, fn) => fn(acc), value);
}Использование в прикладном коде:
import { pipe } from '@/functional/pipe';
const result = pipe(
'{"id":"usr_42","email":"[email protected]","balanceCents":1500}',
parseRawJson,
(either) => Either.flatMap(either, validateAccountDto),
(either) =>
Either.match(
either,
(err) => `Ошибка обработки: ${err.code}`,
(acc) => `Успешно загружен профиль: ${acc.email} ($${acc.balanceCents / 100})`
)
);Почему для связывания двух функций, каждая из которых возвращает Either<E, T>, необходимо использовать flatMap, а не обычный map?
Попытка напрямую прочитать свойство fa.value или ma.right без проверки _tag === 'Some' или _tag === 'Right' ломает гарантии типов и возвращает undefined в рантайме.
// ❌ Антипаттерн: прямое обращение к внутренностям Either
function unsafeUnwrap(result: Either<string, number>): number {
// TypeScript скомпилирует, но в рантайме при Left вернется undefined!
return (result as { right: number }).right;
}
// ✅ Правильное решение: использование match или getOrElse
function safeUnwrap(result: Either<string, number>): number {
return Either.match(
result,
(err) => 0, // Fallback при ошибке
(val) => val
);
}Вызов throw new Error() внутри чистой функции, переданной в Either.map, ломает инкапсуляцию конвейера, прерывает стек вызовов и снова делает ошибку невидимой для системы типов.
Решение: все операции, способные выбросить исключение, должны оборачиваться в Either.tryCatch.
Возврат типа Option<T | null> создает двойную неопределенность. Значение должно нормализоваться сразу на границе ввода через Option.fromNullable(val), после чего работать строго с типом Option<T>.