Канал ошибок
Канал ошибок
Философия обработки ошибок в Effect
Проблема традиционной обработки ошибок
В традиционном JavaScript/TypeScript обработка ошибок страдает от нескольких фундаментальных проблем:
// ❌ Проблема 1: Нетипизированные исключения
function parseJSON(str: string): unknown {
return JSON.parse(str) // Может бросить SyntaxError — но тип не отражает это
}
// ❌ Проблема 2: Потеря информации об ошибке
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error("Failed to fetch") // Потеряли status code, headers, body
return response.json()
}
// ❌ Проблема 3: Невозможность композиции ошибок
try {
await step1()
await step2()
await step3()
} catch (e) {
// Какой именно шаг упал? Какой тип ошибки?
// Все смешано в один catch
}
Подход Effect: ошибки как часть типа
Effect решает эти проблемы через параметр типа E:
Effect<A, E, R>
↑
└── Канал ошибок: типизированные, ожидаемые ошибки
Ключевые принципы:
- Типизация на уровне компиляции — ошибки видны в сигнатуре функции
- Разделение expected и unexpected — разная семантика обработки
- Сохранение контекста — Cause хранит полную историю сбоя
- Композиция ошибок — union types для нескольких типов ошибок
// ✅ Ошибки явно типизированы
class ParseError extends Data.TaggedError("ParseError")<{
readonly input: string
readonly message: string
}> {}
class NetworkError extends Data.TaggedError("NetworkError")<{
readonly url: string
readonly status: number
}> {}
// Тип функции: Effect<User, ParseError | NetworkError, never>
// Компилятор знает ВСЕ возможные ошибки
const fetchAndParseUser = (id: string) => Effect.gen(function* () {
const response = yield* fetchUser(id).pipe(
Effect.mapError((e) => new NetworkError({ url: `/users/${id}`, status: e.status }))
)
return yield* parseUser(response).pipe(
Effect.mapError((e) => new ParseError({ input: response, message: e.message }))
)
})
Два типа ошибок: Expected vs Unexpected
Классификация ошибок
┌─────────────────────────────────────────────────────────────────────┐
│ Ошибки в Effect │
├────────────────────────────────┬────────────────────────────────────┤
│ Expected (Failures) │ Unexpected (Defects) │
├────────────────────────────────┼────────────────────────────────────┤
│ • Часть бизнес-логики │ • Баги, нарушения инвариантов │
│ • Типизированы в E │ • НЕ типизированы (unknown) │
│ • Восстановимы │ • Обычно фатальны │
│ • Effect.fail(error) │ • Effect.die(defect) │
│ • Обрабатываются catchAll │ • Обрабатываются catchAllDefect │
│ │ │
│ Примеры: │ Примеры: │
│ • ValidationError │ • NullPointerException │
│ • NotFoundError │ • ArrayIndexOutOfBounds │
│ • AuthenticationError │ • DivisionByZero │
│ • RateLimitError │ • StackOverflow │
└────────────────────────────────┴────────────────────────────────────┘
Создание expected ошибок
// Способ 1: Data.TaggedError (рекомендуемый)
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly field: string
readonly message: string
}> {}
// Способ 2: Data.Error с кастомным тегом
class NotFoundError extends Data.Error<{
readonly _tag: "NotFoundError"
readonly resource: string
readonly id: string
}> {}
// Способ 3: Plain class с _tag
class AuthError {
readonly _tag = "AuthError" as const
constructor(readonly reason: string) {}
}
// Использование
const validateEmail = (email: string): Effect.Effect<string, ValidationError> =>
email.includes("@")
? Effect.succeed(email)
: Effect.fail(new ValidationError({
field: "email",
message: "Invalid email format"
}))
Создание unexpected ошибок (defects)
// Effect.die — создает defect
const assertNonEmpty = <A>(arr: ReadonlyArray<A>): Effect.Effect<ReadonlyArray<A>> =>
arr.length > 0
? Effect.succeed(arr)
: Effect.die(new Error("Invariant violation: array must not be empty"))
// Effect.dieMessage — сокращение для строки
const assertPositive = (n: number): Effect.Effect<number> =>
n > 0
? Effect.succeed(n)
: Effect.dieMessage(`Expected positive number, got ${n}`)
// Effect.dieSync — ленивое создание defect
const assertDefined = <A>(value: A | undefined): Effect.Effect<A> =>
value !== undefined
? Effect.succeed(value)
: Effect.dieSync(() => new Error("Value is undefined"))
Когда использовать fail vs die
| Ситуация | Использовать | Причина |
|---|---|---|
| Пользователь ввел неверные данные | fail | Ожидаемая ситуация, нужно показать ошибку |
| Ресурс не найден в БД | fail | Бизнес-кейс, можно вернуть 404 |
| Null pointer при обращении к обязательному полю | die | Баг в коде, не должно происходить |
| Сеть недоступна | fail | Временная проблема, можно retry |
| Исчерпана память | die | Фатальная ситуация |
| Rate limit превышен | fail | Ожидаемо при высокой нагрузке |
Канал ошибок E в Effect<A, E, R>
Поведение канала E
Параметр E в Effect<A, E, R> представляет union всех возможных expected ошибок:
// Функция с несколькими типами ошибок
declare const step1: Effect.Effect<number, ErrorA>
declare const step2: (n: number) => Effect.Effect<string, ErrorB>
declare const step3: (s: string) => Effect.Effect<boolean, ErrorC>
// При композиции через flatMap ошибки накапливаются
const program = step1.pipe(
Effect.flatMap(step2),
Effect.flatMap(step3)
)
// Тип: Effect<boolean, ErrorA | ErrorB | ErrorC, never>
Визуализация накопления ошибок
step1: Effect<number, ErrorA, never>
│
▼ flatMap
step2: Effect<string, ErrorB, never>
│
▼ flatMap
step3: Effect<boolean, ErrorC, never>
│
▼
result: Effect<boolean, ErrorA | ErrorB | ErrorC, never>
↑
└── Union всех ошибок в цепочке
Трансформация канала ошибок
class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly query: string
readonly cause: unknown
}> {}
class ServiceError extends Data.TaggedError("ServiceError")<{
readonly service: string
readonly message: string
}> {}
// mapError — преобразование типа ошибки
const fetchFromDB = (query: string) =>
Effect.tryPromise({
try: () => db.execute(query),
catch: (e) => new DatabaseError({ query, cause: e })
})
// Преобразуем DatabaseError в ServiceError
const safeQuery = (query: string) =>
fetchFromDB(query).pipe(
Effect.mapError((dbError) =>
new ServiceError({
service: "database",
message: `Query failed: ${dbError.query}`
})
)
)
// Тип: Effect<Result, ServiceError, never>
Сужение канала ошибок
class NotFoundError extends Data.TaggedError("NotFoundError")<{ readonly id: string }> {}
class ValidationError extends Data.TaggedError("ValidationError")<{ readonly message: string }> {}
class NetworkError extends Data.TaggedError("NetworkError")<{ readonly url: string }> {}
type AppError = NotFoundError | ValidationError | NetworkError
const riskyOperation: Effect.Effect<string, AppError> = Effect.fail(
new NotFoundError({ id: "123" })
)
// catchTag — обработка конкретного типа ошибки
const handled = riskyOperation.pipe(
Effect.catchTag("NotFoundError", (e) =>
Effect.succeed(`Default value for ${e.id}`)
)
)
// Тип: Effect<string, ValidationError | NetworkError, never>
// ↑
// NotFoundError убран из union
// catchTags — обработка нескольких типов
const fullyHandled = riskyOperation.pipe(
Effect.catchTags({
NotFoundError: (e) => Effect.succeed(`Not found: ${e.id}`),
ValidationError: (e) => Effect.succeed(`Invalid: ${e.message}`),
NetworkError: (e) => Effect.succeed(`Network error: ${e.url}`)
})
)
// Тип: Effect<string, never, never>
// ↑
// Все ошибки обработаны
never как отсутствие ошибок
Когда E = never, эффект гарантированно не может завершиться expected ошибкой:
// Эффект без ошибок
const infallible: Effect.Effect<number, never> = Effect.succeed(42)
// Можно запустить через runSync без обработки ошибок
const result = Effect.runSync(infallible) // 42
// После catchAll с recover тип E становится never
const withFallback = Effect.fail("oops").pipe(
Effect.catchAll(() => Effect.succeed("recovered"))
)
// Тип: Effect<string, never, never>
Типизированные ошибки с Data.TaggedError
Зачем нужен _tag
В TypeScript union типов не позволяет напрямую различать элементы:
type Error = ErrorA | ErrorB | ErrorC
function handle(e: Error) {
// Как узнать, какой именно это тип?
// instanceof ненадежен для классов
}
Discriminated unions решают эту проблему через общее поле-дискриминант:
type Error =
| { readonly _tag: "ErrorA"; readonly fieldA: string }
| { readonly _tag: "ErrorB"; readonly fieldB: number }
| { readonly _tag: "ErrorC"; readonly fieldC: boolean }
function handle(e: Error) {
switch (e._tag) {
case "ErrorA": return e.fieldA // TypeScript знает тип
case "ErrorB": return e.fieldB
case "ErrorC": return e.fieldC
}
}
Data.TaggedError — идиоматический способ
// Определение ошибки с автоматическим _tag
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
readonly userId: string
}> {}
class InvalidCredentialsError extends Data.TaggedError("InvalidCredentialsError")<{
readonly username: string
readonly reason: string
}> {}
class SessionExpiredError extends Data.TaggedError("SessionExpiredError")<{
readonly sessionId: string
readonly expiredAt: Date
}> {}
// Использование
const authenticateUser = (
username: string,
password: string
): Effect.Effect<Session, UserNotFoundError | InvalidCredentialsError> =>
Effect.gen(function* () {
const user = yield* findUser(username).pipe(
Effect.mapError(() => new UserNotFoundError({ userId: username }))
)
if (!verifyPassword(user, password)) {
return yield* Effect.fail(new InvalidCredentialsError({
username,
reason: "Password mismatch"
}))
}
return createSession(user)
})
// Pattern matching через catchTags
const handleAuth = authenticateUser("john", "secret123").pipe(
Effect.catchTags({
UserNotFoundError: (e) =>
Effect.succeed({ error: `User ${e.userId} not found` }),
InvalidCredentialsError: (e) =>
Effect.succeed({ error: `Invalid credentials for ${e.username}: ${e.reason}` })
})
)
Преимущества Data.TaggedError
class MyError extends Data.TaggedError("MyError")<{
readonly code: number
readonly message: string
}> {}
// 1. Автоматический _tag
const error = new MyError({ code: 404, message: "Not found" })
console.log(error._tag) // "MyError"
// 2. Структурное равенство (через Data)
const error1 = new MyError({ code: 404, message: "Not found" })
const error2 = new MyError({ code: 404, message: "Not found" })
console.log(Equal.equals(error1, error2)) // true
// 3. Иммутабельность
// @ts-expect-error — readonly свойства
error.code = 500
// 4. Совместимость с Effect
const failingEffect = Effect.fail(new MyError({ code: 500, message: "Server error" }))
// 5. Автоматический toString
console.log(String(error))
// MyError: { "code": 404, "message": "Not found" }
// 6. Наследование от Error
console.log(error instanceof Error) // true
console.log(error.name) // "MyError"
console.log(error.stack) // Stack trace
Иерархии ошибок
// Базовый тип для всех ошибок домена
type DomainError =
| ValidationError
| BusinessError
| InfrastructureError
// Группа ошибок валидации
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly field: string
readonly constraint: string
}> {}
// Группа бизнес-ошибок
type BusinessError =
| InsufficientFundsError
| AccountLockedError
class InsufficientFundsError extends Data.TaggedError("InsufficientFundsError")<{
readonly accountId: string
readonly required: number
readonly available: number
}> {}
class AccountLockedError extends Data.TaggedError("AccountLockedError")<{
readonly accountId: string
readonly reason: string
}> {}
// Группа инфраструктурных ошибок
type InfrastructureError =
| DatabaseError
| NetworkError
class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly query: string
readonly originalError: unknown
}> {}
class NetworkError extends Data.TaggedError("NetworkError")<{
readonly endpoint: string
readonly statusCode: number
}> {}
// Обработка через Match
const handleDomainError = (error: DomainError): string =>
Match.value(error).pipe(
Match.tag("ValidationError", (e) => `Validation failed: ${e.field} - ${e.constraint}`),
Match.tag("InsufficientFundsError", (e) =>
`Insufficient funds: need ${e.required}, have ${e.available}`),
Match.tag("AccountLockedError", (e) => `Account ${e.accountId} locked: ${e.reason}`),
Match.tag("DatabaseError", (e) => `Database error in query: ${e.query}`),
Match.tag("NetworkError", (e) => `Network error: ${e.endpoint} returned ${e.statusCode}`),
Match.exhaustive
)
Cause: полная картина сбоя
Что такое Cause
Cause<E> — это структура данных, которая хранит полную информацию о том, почему эффект не удался. В отличие от простого E, Cause захватывает:
- Expected ошибки (Fail)
- Unexpected defects (Die)
- Прерывания (Interrupt)
- Последовательные сбои (Sequential)
- Параллельные сбои (Parallel)
- Пустые сбои (Empty)
Структура Cause
Cause<E>
├── Empty — Нет ошибки (используется как identity для композиции)
├── Fail<E> — Expected ошибка с типом E
├── Die — Unexpected defect (unknown)
├── Interrupt — Прерывание Fiber
├── Sequential — Последовательная композиция двух Cause
└── Parallel — Параллельная композиция двух Cause
Визуально:
Cause<E>
/ | \
/ | \
Fail Die Interrupt
│ │ │
E unknown FiberId
Sequential<E> Parallel<E>
/ \ / \
Cause<E> Cause<E> Cause<E> Cause<E>
Типы Cause
// 1. Fail — expected ошибка
const failCause: Cause.Cause<string> = Cause.fail("Something went wrong")
// 2. Die — defect
const dieCause: Cause.Cause<never> = Cause.die(new Error("Unexpected crash"))
// 3. Interrupt — прерывание
const interruptCause: Cause.Cause<never> = Cause.interrupt(FiberId.none)
// 4. Empty — пустая причина
const emptyCause: Cause.Cause<never> = Cause.empty
// 5. Sequential — последовательные ошибки
const sequentialCause: Cause.Cause<string> = Cause.sequential(
Cause.fail("First error"),
Cause.fail("Second error")
)
// 6. Parallel — параллельные ошибки
const parallelCause: Cause.Cause<string> = Cause.parallel(
Cause.fail("Error in fiber 1"),
Cause.fail("Error in fiber 2")
)
Извлечение информации из Cause
// Создадим сложную причину
const complexCause = Cause.sequential(
Cause.parallel(
Cause.fail("Network timeout"),
Cause.die(new TypeError("null is not an object"))
),
Cause.fail("Validation failed")
)
// Извлечение failures (expected ошибок)
const failures: Chunk.Chunk<string> = Cause.failures(complexCause)
console.log([...failures])
// ["Network timeout", "Validation failed"]
// Извлечение defects (unexpected)
const defects: Chunk.Chunk<unknown> = Cause.defects(complexCause)
console.log([...defects])
// [TypeError: null is not an object]
// Получение первой ошибки
const firstFailure: string | undefined = Cause.failureOption(complexCause).pipe(
(opt) => opt._tag === "Some" ? opt.value : undefined
)
// "Network timeout"
// Проверка типа Cause
console.log(Cause.isFailure(Cause.fail("error"))) // true
console.log(Cause.isDie(Cause.die(new Error()))) // true
console.log(Cause.isInterrupted(complexCause)) // false
console.log(Cause.isEmpty(Cause.empty)) // true
Pretty printing Cause
const cause = Cause.sequential(
Cause.fail({ type: "ValidationError", field: "email" }),
Cause.die(new Error("Database connection lost"))
)
// Человекочитаемое представление
console.log(Cause.pretty(cause))
/*
All fibers interrupted without errors.
▼ Cause stack:
├─ Error: ValidationError
│ at ...
├─ Die: Error: Database connection lost
│ at ...
*/
// Для отладки — полная структура
console.log(JSON.stringify(cause, null, 2))
Когда возникают Sequential и Parallel
// Sequential — при использовании ensuring/finalizers
const withFinalizer = Effect.fail("Main error").pipe(
Effect.ensuring(Effect.die("Finalizer crashed"))
)
// Cause: Sequential(Fail("Main error"), Die("Finalizer crashed"))
// Parallel — при параллельном выполнении
const parallel = Effect.all([
Effect.fail("Error A"),
Effect.fail("Error B")
], { concurrency: "unbounded" })
// Cause: Parallel(Fail("Error A"), Fail("Error B"))
// Или при использовании race
const race = Effect.race(
Effect.fail("Loser A"),
Effect.fail("Loser B")
)
// Может быть Parallel если оба упали "одновременно"
Exit: финальный результат вычисления
Что такое Exit
Exit<A, E> — это финальный результат выполнения Effect. Это sum type с двумя вариантами:
type Exit<A, E> = Success<A> | Failure<E>
// Success содержит значение A
interface Success<A> {
readonly _tag: "Success"
readonly value: A
}
// Failure содержит Cause<E>
interface Failure<E> {
readonly _tag: "Failure"
readonly cause: Cause<E>
}
Отношения между Effect, Exit и Cause
Effect<A, E, R>
│
│ run
▼
Exit<A, E>
/ \
/ \
Success<A> Failure<E>
│ │
│ │
A Cause<E>
/ | \
Fail Die Interrupt
Создание Exit
// Success
const success: Exit.Exit<number, never> = Exit.succeed(42)
// Failure с Fail
const failure: Exit.Exit<never, string> = Exit.fail("Something went wrong")
// Failure с Die
const defect: Exit.Exit<never, never> = Exit.die(new Error("Crash"))
// Failure с Interrupt
const interrupted: Exit.Exit<never, never> = Exit.interrupt(FiberId.none)
// Из Cause
const fromCause: Exit.Exit<never, string> = Exit.failCause(
Cause.parallel(
Cause.fail("Error 1"),
Cause.fail("Error 2")
)
)
Работа с Exit
// Получение Exit через runPromiseExit / runSyncExit
const program = Effect.gen(function* () {
const a = yield* Effect.succeed(10)
const b = yield* Effect.succeed(20)
return a + b
})
const exit = Effect.runSyncExit(program)
// Pattern matching
if (Exit.isSuccess(exit)) {
console.log("Success:", exit.value) // 30
} else {
console.log("Failure:", Cause.pretty(exit.cause))
}
// Использование Match
const result = Match.value(exit).pipe(
Match.tag("Success", ({ value }) => `Result: ${value}`),
Match.tag("Failure", ({ cause }) => `Error: ${Cause.pretty(cause)}`),
Match.exhaustive
)
// Извлечение через Option
const valueOpt: Option.Option<number> = Exit.getOrElse(exit, () => 0) // 30 или 0
Трансформации Exit
const exit: Exit.Exit<number, string> = Exit.succeed(42)
// map — преобразование успешного значения
const mapped: Exit.Exit<string, string> = Exit.map(exit, (n) => `Value: ${n}`)
// Exit.succeed("Value: 42")
// mapError — преобразование ошибки
const failedExit: Exit.Exit<number, string> = Exit.fail("oops")
const mappedError: Exit.Exit<number, Error> = Exit.mapError(
failedExit,
(e) => new Error(e)
)
// flatMap — композиция
const composed: Exit.Exit<string, string> = Exit.flatMap(
exit,
(n) => n > 0 ? Exit.succeed(`Positive: ${n}`) : Exit.fail("Non-positive")
)
// zip — объединение двух Exit
const exit1: Exit.Exit<number, string> = Exit.succeed(10)
const exit2: Exit.Exit<number, string> = Exit.succeed(20)
const zipped: Exit.Exit<readonly [number, number], string> = Exit.zip(exit1, exit2)
// Exit.succeed([10, 20])
// При наличии ошибки — объединяются в Parallel Cause
const fail1 = Exit.fail("Error 1")
const fail2 = Exit.fail("Error 2")
const zippedFails = Exit.zip(fail1, fail2)
// Exit.failCause(Cause.parallel(Cause.fail("Error 1"), Cause.fail("Error 2")))
Преобразование Exit в Effect
// Из Exit обратно в Effect
const exit: Exit.Exit<number, string> = Exit.succeed(42)
const effect: Effect.Effect<number, string> = Effect.exit(exit)
// Или использовать Exit напрямую в Effect.gen
const program = Effect.gen(function* () {
const result = yield* Effect.exit(riskyOperation)
if (Exit.isSuccess(result)) {
return result.value * 2
} else {
// Анализируем причину сбоя
const failures = Cause.failures(result.cause)
console.log("Errors:", [...failures])
return 0
}
})
Работа с Cause
Sandbox и Unsandbox
Effect.sandbox “поднимает” полную Cause в канал ошибок, позволяя работать с ней напрямую:
// Обычный эффект
const risky: Effect.Effect<number, string> = Effect.fail("Expected error")
// После sandbox: Cause<E> становится ошибкой
const sandboxed: Effect.Effect<number, Cause.Cause<string>> = Effect.sandbox(risky)
// Теперь можем обрабатывать полную Cause
const handled = sandboxed.pipe(
Effect.catchAll((cause) => {
if (Cause.isFailure(cause)) {
const failures = [...Cause.failures(cause)]
return Effect.succeed(failures.length)
}
if (Cause.isDie(cause)) {
console.error("Defect detected:", Cause.defects(cause))
return Effect.succeed(-1)
}
return Effect.succeed(0)
})
)
// unsandbox — обратная операция
const original: Effect.Effect<number, string> = Effect.unsandbox(sandboxed)
Визуализация sandbox
Effect<A, E, R>
│
│ sandbox
▼
Effect<A, Cause<E>, R> ← Теперь Cause<E> — обычная ошибка
│
│ catchAll/mapError
▼
Effect<A, NewE, R> ← Можно трансформировать Cause
│
│ unsandbox
▼
Effect<A, E, R> ← Обратно к исходному типу
catchAllCause — полный доступ к Cause
const riskyOperation: Effect.Effect<string, Error> = Effect.gen(function* () {
yield* Effect.fail(new Error("Expected failure"))
return "never reached"
})
// catchAllCause получает полную Cause
const resilient = riskyOperation.pipe(
Effect.catchAllCause((cause) => {
// Проверяем, есть ли defects
const defects = [...Cause.defects(cause)]
if (defects.length > 0) {
console.error("DEFECT DETECTED:", defects)
return Effect.succeed("recovered from defect")
}
// Проверяем прерывание
if (Cause.isInterrupted(cause)) {
console.log("Was interrupted")
return Effect.succeed("recovered from interrupt")
}
// Обычные ошибки
const failures = [...Cause.failures(cause)]
console.log("Failures:", failures.map(e => e.message))
return Effect.succeed("recovered from failures")
})
)
// Тип: Effect<string, never, never>
// Все ошибки обработаны, включая defects
catchSomeCause — выборочная обработка
const program = Effect.fail("oops").pipe(
Effect.catchSomeCause((cause) => {
// Обрабатываем только если это один Fail
const failure = Cause.failureOption(cause)
if (Option.isSome(failure) && failure.value === "oops") {
return Option.some(Effect.succeed("handled oops"))
}
return Option.none() // Не обрабатываем, пробрасываем дальше
})
)
Absorb — преобразование defects в failures
// Эффект с defect
const withDefect: Effect.Effect<never, never> = Effect.die(new Error("Crash!"))
// absorb превращает ВСЕ Cause в единую ошибку
const absorbed: Effect.Effect<never, unknown> = Effect.absorb(withDefect)
// Теперь defect стал expected ошибкой
// Можно указать тип через аннотацию
const absorbedTyped = Effect.absorb(withDefect) as Effect.Effect<never, Error>
Паттерны обработки ошибок
Паттерн: Graceful Degradation
class CacheError extends Data.TaggedError("CacheError")<{
readonly key: string
}> {}
class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly query: string
}> {}
// Иерархия fallback-ов
const fetchUserData = (userId: string) =>
fetchFromCache(userId).pipe(
// Если кэш недоступен — идем в БД
Effect.catchTag("CacheError", (e) => {
console.log(`Cache miss for ${e.key}, falling back to DB`)
return fetchFromDatabase(userId)
}),
// Если БД недоступна — возвращаем дефолт
Effect.catchTag("DatabaseError", (e) => {
console.log(`DB error for ${e.query}, using default`)
return Effect.succeed(defaultUserData)
}),
// Добавляем timeout на всю цепочку
Effect.timeout(Duration.seconds(5)),
// Если timeout — используем stale данные
Effect.catchTag("TimeoutException", () =>
Effect.succeed(staleUserData)
)
)
Паттерн: Error Accumulation
class FieldError extends Data.TaggedError("FieldError")<{
readonly field: string
readonly message: string
}> {}
// Валидация с накоплением всех ошибок
const validateForm = (form: FormData) =>
Effect.all({
email: validateEmail(form.email),
password: validatePassword(form.password),
age: validateAge(form.age)
}, {
mode: "either" // Возвращает Either вместо fail при первой ошибке
}).pipe(
Effect.flatMap((results) => {
const errors: FieldError[] = []
const values: Record<string, unknown> = {}
for (const [field, result] of Object.entries(results)) {
if (Either.isLeft(result)) {
errors.push(result.left)
} else {
values[field] = result.right
}
}
if (errors.length > 0) {
return Effect.fail({ _tag: "ValidationErrors" as const, errors })
}
return Effect.succeed(values as ValidatedForm)
})
)
Паттерн: Error Enrichment
class EnrichedError extends Data.TaggedError("EnrichedError")<{
readonly originalError: unknown
readonly context: {
readonly operation: string
readonly userId?: string
readonly timestamp: Date
readonly requestId: string
}
}> {}
// Обертка для добавления контекста к любой ошибке
const withErrorContext = <A, E, R>(
effect: Effect.Effect<A, E, R>,
context: Omit<EnrichedError["context"], "timestamp">
): Effect.Effect<A, EnrichedError, R> =>
effect.pipe(
Effect.mapError((e) => new EnrichedError({
originalError: e,
context: {
...context,
timestamp: new Date()
}
}))
)
// Использование
const enrichedFetch = withErrorContext(
fetchUser("123"),
{ operation: "fetchUser", userId: "123", requestId: crypto.randomUUID() }
)
Паттерн: Defect Isolation
// Изолируем потенциальные defects в отдельный канал
const isolateDefects = <A, E, R>(
effect: Effect.Effect<A, E, R>
): Effect.Effect<A, E | { _tag: "Defect"; defect: unknown }, R> =>
effect.pipe(
Effect.catchAllDefect((defect) =>
Effect.fail({ _tag: "Defect" as const, defect })
)
)
// Теперь defects стали частью типа ошибки
const program = isolateDefects(
Effect.sync(() => {
throw new Error("Unexpected!")
})
)
// Тип: Effect<never, { _tag: "Defect"; defect: unknown }, never>
Паттерн: Retry with Error Inspection
class RetryableError extends Data.TaggedError("RetryableError")<{
readonly reason: string
}> {}
class FatalError extends Data.TaggedError("FatalError")<{
readonly reason: string
}> {}
// Retry только для определенных типов ошибок
const smartRetry = <A, R>(
effect: Effect.Effect<A, RetryableError | FatalError, R>
): Effect.Effect<A, FatalError, R> =>
effect.pipe(
Effect.retry({
schedule: Schedule.exponential(Duration.millis(100)).pipe(
Schedule.compose(Schedule.recurs(3))
),
while: (error): error is RetryableError => error._tag === "RetryableError"
})
)
// После retry остаются только FatalError
Упражнения
Basic: Определение ошибок домена
Создайте типизированные ошибки для системы заказов:
// TODO: Определите ошибки
// - ProductNotFoundError: productId
// - InsufficientStockError: productId, requested, available
// - PaymentDeclinedError: orderId, reason
// - ShippingUnavailableError: address, reason
// TODO: Реализуйте функцию с правильной типизацией ошибок
const placeOrder = (
productId: string,
quantity: number,
paymentMethod: PaymentMethod,
shippingAddress: Address
): Effect.Effect<Order, ???, never> => {
// Реализация
}
Решение
// Определение ошибок
class ProductNotFoundError extends Data.TaggedError("ProductNotFoundError")<{
readonly productId: string
}> {}
class InsufficientStockError extends Data.TaggedError("InsufficientStockError")<{
readonly productId: string
readonly requested: number
readonly available: number
}> {}
class PaymentDeclinedError extends Data.TaggedError("PaymentDeclinedError")<{
readonly orderId: string
readonly reason: string
}> {}
class ShippingUnavailableError extends Data.TaggedError("ShippingUnavailableError")<{
readonly address: string
readonly reason: string
}> {}
type OrderError =
| ProductNotFoundError
| InsufficientStockError
| PaymentDeclinedError
| ShippingUnavailableError
// Типы
interface PaymentMethod {
readonly type: "card" | "paypal"
readonly details: string
}
interface Address {
readonly street: string
readonly city: string
readonly country: string
}
interface Product {
readonly id: string
readonly name: string
readonly price: number
readonly stock: number
}
interface Order {
readonly id: string
readonly productId: string
readonly quantity: number
readonly totalPrice: number
readonly status: "confirmed"
}
// Mock репозитории
const findProduct = (productId: string): Effect.Effect<Product, ProductNotFoundError> =>
productId === "valid-product"
? Effect.succeed({ id: productId, name: "Widget", price: 100, stock: 10 })
: Effect.fail(new ProductNotFoundError({ productId }))
const processPayment = (
orderId: string,
amount: number,
method: PaymentMethod
): Effect.Effect<void, PaymentDeclinedError> =>
method.type === "card"
? Effect.succeed(undefined)
: Effect.fail(new PaymentDeclinedError({ orderId, reason: "PayPal unavailable" }))
const validateShipping = (
address: Address
): Effect.Effect<void, ShippingUnavailableError> =>
address.country === "Restricted"
? Effect.fail(new ShippingUnavailableError({
address: `${address.street}, ${address.city}`,
reason: "Country not supported"
}))
: Effect.succeed(undefined)
// Реализация
const placeOrder = (
productId: string,
quantity: number,
paymentMethod: PaymentMethod,
shippingAddress: Address
): Effect.Effect<Order, OrderError> =>
Effect.gen(function* () {
// 1. Проверяем наличие товара
const product = yield* findProduct(productId)
// 2. Проверяем доступное количество
if (product.stock < quantity) {
return yield* Effect.fail(new InsufficientStockError({
productId,
requested: quantity,
available: product.stock
}))
}
// 3. Проверяем возможность доставки
yield* validateShipping(shippingAddress)
// 4. Создаем заказ
const orderId = crypto.randomUUID()
const totalPrice = product.price * quantity
// 5. Обрабатываем платеж
yield* processPayment(orderId, totalPrice, paymentMethod)
// 6. Возвращаем заказ
return {
id: orderId,
productId,
quantity,
totalPrice,
status: "confirmed" as const
}
})
// Тест
const test = placeOrder(
"valid-product",
5,
{ type: "card", details: "4111-xxxx" },
{ street: "123 Main St", city: "NYC", country: "USA" }
).pipe(
Effect.catchTags({
ProductNotFoundError: (e) =>
Effect.succeed({ error: `Product ${e.productId} not found` }),
InsufficientStockError: (e) =>
Effect.succeed({ error: `Only ${e.available} items available` }),
PaymentDeclinedError: (e) =>
Effect.succeed({ error: `Payment failed: ${e.reason}` }),
ShippingUnavailableError: (e) =>
Effect.succeed({ error: `Cannot ship to ${e.address}` })
})
)
Effect.runPromise(test).then(console.log)
Intermediate: Анализ Cause
Напишите функцию, которая анализирует Cause и генерирует отчет:
interface ErrorReport {
readonly failures: ReadonlyArray<string>
readonly defects: ReadonlyArray<string>
readonly wasInterrupted: boolean
readonly isSequential: boolean
readonly isParallel: boolean
readonly severity: "low" | "medium" | "high" | "critical"
}
// TODO: Реализуйте функцию
const analyzeFailure = <E>(cause: Cause.Cause<E>): ErrorReport => {
// ???
}
// Тесты
const simpleFail = Cause.fail("oops")
const defect = Cause.die(new Error("crash"))
const parallel = Cause.parallel(Cause.fail("a"), Cause.fail("b"))
const complex = Cause.sequential(
parallel,
Cause.die(new TypeError("null"))
)
Решение
interface ErrorReport {
readonly failures: ReadonlyArray<string>
readonly defects: ReadonlyArray<string>
readonly wasInterrupted: boolean
readonly isSequential: boolean
readonly isParallel: boolean
readonly severity: "low" | "medium" | "high" | "critical"
}
const analyzeFailure = <E>(cause: Cause.Cause<E>): ErrorReport => {
// Извлекаем failures
const failures = Chunk.toReadonlyArray(
Cause.failures(cause)
).map((f) => String(f))
// Извлекаем defects
const defects = Chunk.toReadonlyArray(
Cause.defects(cause)
).map((d) => d instanceof Error ? d.message : String(d))
// Проверяем прерывание
const wasInterrupted = Cause.isInterrupted(cause)
// Проверяем структуру — используем reduce для обхода
let isSequential = false
let isParallel = false
const checkStructure = (c: Cause.Cause<E>): void => {
if (c._tag === "Sequential") {
isSequential = true
checkStructure(c.left)
checkStructure(c.right)
} else if (c._tag === "Parallel") {
isParallel = true
checkStructure(c.left)
checkStructure(c.right)
}
}
checkStructure(cause)
// Определяем severity
const calculateSeverity = (): "low" | "medium" | "high" | "critical" => {
// Critical: есть defects или прерывания
if (defects.length > 0) return "critical"
if (wasInterrupted) return "high"
// High: параллельные ошибки (много сбоев одновременно)
if (isParallel && failures.length > 2) return "high"
// Medium: последовательные или несколько ошибок
if (isSequential || failures.length > 1) return "medium"
// Low: единичная ошибка
return "low"
}
return {
failures,
defects,
wasInterrupted,
isSequential,
isParallel,
severity: calculateSeverity()
}
}
// Тесты
const simpleFail = Cause.fail("oops")
console.log(analyzeFailure(simpleFail))
// { failures: ["oops"], defects: [], wasInterrupted: false,
// isSequential: false, isParallel: false, severity: "low" }
const defect = Cause.die(new Error("crash"))
console.log(analyzeFailure(defect))
// { failures: [], defects: ["crash"], wasInterrupted: false,
// isSequential: false, isParallel: false, severity: "critical" }
const parallel = Cause.parallel(
Cause.fail("a"),
Cause.parallel(Cause.fail("b"), Cause.fail("c"))
)
console.log(analyzeFailure(parallel))
// { failures: ["a", "b", "c"], defects: [], wasInterrupted: false,
// isSequential: false, isParallel: true, severity: "high" }
const complex = Cause.sequential(
parallel,
Cause.die(new TypeError("null is not an object"))
)
console.log(analyzeFailure(complex))
// { failures: ["a", "b", "c"], defects: ["null is not an object"],
// wasInterrupted: false, isSequential: true, isParallel: true,
// severity: "critical" }
Advanced: Custom Error Recovery Strategy
Реализуйте систему восстановления с приоритетами и fallback стратегиями:
// Стратегии восстановления
type RecoveryStrategy<E, A> =
| { readonly _tag: "Retry"; readonly maxAttempts: number; readonly delay: Duration.Duration }
| { readonly _tag: "Fallback"; readonly value: A }
| { readonly _tag: "Escalate"; readonly transform: (e: E) => Error }
| { readonly _tag: "Ignore" }
interface RecoveryConfig<E, A> {
readonly strategies: ReadonlyMap<string, RecoveryStrategy<E, A>>
readonly defaultStrategy: RecoveryStrategy<E, A>
}
// TODO: Реализуйте recoverable
const recoverable = <A, E extends { readonly _tag: string }, R>(
effect: Effect.Effect<A, E, R>,
config: RecoveryConfig<E, A>
): Effect.Effect<A, Error, R> => {
// ???
}
Решение
// Стратегии восстановления
type RecoveryStrategy<E, A> =
| { readonly _tag: "Retry"; readonly maxAttempts: number; readonly delay: Duration.Duration }
| { readonly _tag: "Fallback"; readonly value: A }
| { readonly _tag: "Escalate"; readonly transform: (e: E) => Error }
| { readonly _tag: "Ignore" }
interface RecoveryConfig<E, A> {
readonly strategies: ReadonlyMap<string, RecoveryStrategy<E, A>>
readonly defaultStrategy: RecoveryStrategy<E, A>
}
// Helper для применения стратегии
const applyStrategy = <A, E extends { readonly _tag: string }, R>(
effect: Effect.Effect<A, E, R>,
error: E,
strategy: RecoveryStrategy<E, A>
): Effect.Effect<A, Error, R> => {
switch (strategy._tag) {
case "Retry":
return effect.pipe(
Effect.retry(
Schedule.recurs(strategy.maxAttempts - 1).pipe(
Schedule.addDelay(() => strategy.delay)
)
),
Effect.mapError((e) => new Error(`Retry exhausted: ${e._tag}`))
)
case "Fallback":
return Effect.succeed(strategy.value)
case "Escalate":
return Effect.fail(strategy.transform(error))
case "Ignore":
return Effect.succeed(undefined as unknown as A) // Осторожно с типами!
}
}
const recoverable = <A, E extends { readonly _tag: string }, R>(
effect: Effect.Effect<A, E, R>,
config: RecoveryConfig<E, A>
): Effect.Effect<A, Error, R> =>
effect.pipe(
Effect.catchAll((error) => {
// Ищем стратегию по _tag ошибки
const strategy = config.strategies.get(error._tag) ?? config.defaultStrategy
// Логируем решение
const logDecision = Effect.sync(() => {
console.log(`[Recovery] Error: ${error._tag}, Strategy: ${strategy._tag}`)
})
return logDecision.pipe(
Effect.flatMap(() => applyStrategy(effect, error, strategy))
)
})
)
// Пример использования
class NetworkError extends Data.TaggedError("NetworkError")<{
readonly url: string
}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly field: string
}> {}
class TimeoutError extends Data.TaggedError("TimeoutError")<{
readonly duration: number
}> {}
type AppError = NetworkError | ValidationError | TimeoutError
// Конфигурация стратегий
const recoveryConfig: RecoveryConfig<AppError, string> = {
strategies: new Map([
["NetworkError", {
_tag: "Retry",
maxAttempts: 3,
delay: Duration.millis(500)
}],
["ValidationError", {
_tag: "Escalate",
transform: (e) => new Error(`Validation failed: ${(e as ValidationError).field}`)
}],
["TimeoutError", {
_tag: "Fallback",
value: "default-value"
}]
]),
defaultStrategy: {
_tag: "Escalate",
transform: (e) => new Error(`Unhandled error: ${e._tag}`)
}
}
// Тестируем
let attempts = 0
const flakyNetwork: Effect.Effect<string, NetworkError> = Effect.suspend(() => {
attempts++
if (attempts < 3) {
return Effect.fail(new NetworkError({ url: "/api/data" }))
}
return Effect.succeed("success after retries")
})
const program = recoverable(flakyNetwork, recoveryConfig)
Effect.runPromise(program).then(console.log)
// [Recovery] Error: NetworkError, Strategy: Retry
// [Recovery] Error: NetworkError, Strategy: Retry
// success after retries
Ключевые выводы
-
Два типа ошибок — Expected (failures, типизированы в E) и Unexpected (defects, unknown)
-
Data.TaggedError — идиоматический способ создания типизированных ошибок с discriminated union
-
Канал E — union всех возможных ошибок, накапливается при композиции через flatMap
-
Cause — полная структура сбоя:
Fail— expected ошибкаDie— defectInterrupt— прерываниеSequential/Parallel— композиция
-
Exit — финальный результат:
Success<A>— успешное значениеFailure<E>— содержитCause<E>
-
Операции с ошибками:
catchTag/catchTags— обработка по типуmapError— трансформация типа ошибкиsandbox/unsandbox— доступ к полной CausecatchAllCause— обработка включая defects
-
Паттерны:
- Graceful degradation (fallback цепочки)
- Error accumulation (сбор всех ошибок)
- Error enrichment (добавление контекста)
- Defect isolation (преобразование в failures)