Effect Курс Канал ошибок

Канал ошибок

Канал ошибок

Философия обработки ошибок в 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>

         └── Канал ошибок: типизированные, ожидаемые ошибки

Ключевые принципы:

  1. Типизация на уровне компиляции — ошибки видны в сигнатуре функции
  2. Разделение expected и unexpected — разная семантика обработки
  3. Сохранение контекста — Cause хранит полную историю сбоя
  4. Композиция ошибок — 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

Ключевые выводы

  1. Два типа ошибок — Expected (failures, типизированы в E) и Unexpected (defects, unknown)

  2. Data.TaggedError — идиоматический способ создания типизированных ошибок с discriminated union

  3. Канал E — union всех возможных ошибок, накапливается при композиции через flatMap

  4. Cause — полная структура сбоя:

    • Fail — expected ошибка
    • Die — defect
    • Interrupt — прерывание
    • Sequential / Parallel — композиция
  5. Exit — финальный результат:

    • Success<A> — успешное значение
    • Failure<E> — содержит Cause<E>
  6. Операции с ошибками:

    • catchTag / catchTags — обработка по типу
    • mapError — трансформация типа ошибки
    • sandbox / unsandbox — доступ к полной Cause
    • catchAllCause — обработка включая defects
  7. Паттерны:

    • Graceful degradation (fallback цепочки)
    • Error accumulation (сбор всех ошибок)
    • Error enrichment (добавление контекста)
    • Defect isolation (преобразование в failures)