У традиційній розробці на 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.
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})`
)
);Спроба напряму прочитати властивість 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>.
Створити дискриміноване об'єднання типів помилок (DomainError) з унікальним рядковим тегом code.
Замінити виклики fetch, JSON.parse, fs.readFileSync на виклики функцій, що повертають Either<E, A>.
Вибудувати лінійний конвеєр обчислень, передаючи результат попереднього кроку в наступний через Either.flatMap.
На межі застосунку (HTTP контролер, UI-компонент) виконати match для вичерпної обробки успіхів та збоїв.
Видалити застарілі блоки перехоплення винятків, зробивши стек викликів чистим та передбачуваним.