Effect Курс Cause: Анатомия причин сбоев

Cause: Анатомия причин сбоев

Глубокое погружение в структуру данных Cause<E>.

Теория

Зачем нужен Cause?

В традиционном JavaScript при возникновении ошибки мы получаем только один объект Error. Но в реальных системах ситуация гораздо сложнее:

  • Что если одновременно произошли две ошибки в параллельных вычислениях?
  • Что если ошибка в финализаторе наложилась на основную ошибку?
  • Что если fiber был прерван в процессе обработки другой ошибки?

Effect решает эти проблемы через тип Cause<E> — алгебраическую структуру данных, способную представить любую комбинацию причин сбоя.

Визуализация структуры Cause

                              Cause<E>

         ┌──────────┬─────────────┼─────────────┬──────────┐
         │          │             │             │          │
       Empty      Fail<E>       Die        Interrupt    Composite
         │          │             │             │          │
    (no error)  (expected)  (unexpected)   (fiber)    ┌───┴───┐
                                           canceled   │       │
                                                 Sequential  Parallel
                                                    (a;b)     (a||b)

Ключевые свойства Cause

СвойствоОписание
ПолнотаПредставляет ВСЕ возможные причины сбоя
КомпозиционностьПричины могут комбинироваться последовательно и параллельно
ТипизацияОшибки Fail<E> типизированы, дефекты Die — нет
ИерархичностьОбразует дерево причин с произвольной вложенностью

Концепция ФП

Cause как Algebraic Data Type (ADT)

Cause<E> — это классический Sum Type (тип-сумма) из функционального программирования:

type Cause<E> = 
  | Empty                        // Отсутствие ошибки
  | Fail<E>                      // Ожидаемая ошибка
  | Die                          // Неожиданный дефект
  | Interrupt                    // Прерывание fiber
  | Sequential<Cause<E>>         // Последовательная композиция
  | Parallel<Cause<E>>           // Параллельная композиция

Semigroup для Cause

Cause образует полугруппу относительно двух операций:

  1. Sequential — последовательная композиция: cause1 ; cause2
  2. Parallel — параллельная композиция: cause1 || cause2
Sequential: Cause<E> × Cause<E> → Cause<E>
Parallel:   Cause<E> × Cause<E> → Cause<E>

Эти операции ассоциативны, что позволяет строить цепочки любой длины.

Functor для Cause

Cause<E> является функтором по типу ошибки E:

Cause.map: <E, E2>(cause: Cause<E>, f: (e: E) => E2) => Cause<E2>

Это позволяет трансформировать ошибки внутри Cause, сохраняя структуру.


Варианты Cause

1. Empty — Отсутствие ошибки

Empty представляет успешное завершение без ошибок. Это нейтральный элемент для композиции.


// Создание Empty
const empty = Cause.empty

// Проверка на пустоту
console.log(Cause.isEmpty(empty)) // true

⚠️ Важно: Empty редко встречается явно — обычно успешные операции представлены через Exit.Success.

2. Fail<E> — Ожидаемая ошибка

Fail<E> представляет типизированную ожидаемую ошибку — ту, которую мы явно указали в типе Effect<A, E, R>.


// Создание Fail напрямую
const failCause = Cause.fail("Database connection refused")

// Создание Effect с Fail cause
const failedEffect = Effect.failCause(Cause.fail(new Error("Not found")))

// Типичный способ — через Effect.fail
const typical = Effect.fail("Something went wrong")
// Внутренне создаёт Cause.fail("Something went wrong")

📊 Визуализация Fail:

Fail<E>

  └── error: E   ← типизированное значение ошибки

3. Die — Неожиданный дефект

Die представляет нетипизированный дефект — неожиданную ошибку, которая не входит в контракт функции.


// Создание Die напрямую
const dieCause = Cause.die(new Error("Unexpected null pointer"))

// Через Effect.die
const dieEffect = Effect.die("System crash!")

// Часто возникает из throw внутри Effect.sync
const fromThrow = Effect.sync(() => {
  throw new Error("Oops!") // Станет Cause.die
})

📊 Визуализация Die:

Die

  └── defect: unknown   ← любое значение (не типизировано)

💡 Ключевое отличие Fail от Die:

  • Fail<E> — часть контракта, видна в типе, должна быть обработана
  • Die — баг или невосстановимая ошибка, не влияет на тип

4. Interrupt — Прерывание Fiber

Interrupt представляет прерывание выполнения fiber. Содержит FiberId прерванного fiber.


// Создание Interrupt напрямую
const interruptCause = Cause.interrupt(FiberId.none)

// Типичный способ — через Fiber.interrupt
const interruptedProgram = Effect.gen(function* () {
  const fiber = yield* Effect.fork(
    Effect.sleep("1 second").pipe(Effect.as("done"))
  )
  yield* Effect.yieldNow()
  yield* Effect.interruptFiber(fiber)
  const exit = yield* Effect.exit(fiber.await)
  return exit
})

📊 Визуализация Interrupt:

Interrupt

  └── fiberId: FiberId   ← идентификатор прерванного fiber

5. Sequential — Последовательная композиция

Sequential объединяет две причины, произошедшие последовательно (одна за другой).

Типичный сценарий: ошибка в основном коде + ошибка в финализаторе


// Создание Sequential напрямую
const seqCause = Cause.sequential(
  Cause.fail("Primary error"),
  Cause.die("Finalizer crashed")
)

// Типичный сценарий — через ensuring
const withFinalizer = Effect.gen(function* () {
  yield* Effect.fail("Main operation failed")
}).pipe(
  Effect.ensuring(Effect.die("Cleanup also failed!"))
)

// Результат — Sequential cause
Effect.runPromiseExit(withFinalizer).then(console.log)
/*
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: {
    _id: 'Cause',
    _tag: 'Sequential',
    left: { _id: 'Cause', _tag: 'Fail', failure: 'Main operation failed' },
    right: { _id: 'Cause', _tag: 'Die', defect: 'Cleanup also failed!' }
  }
}
*/

📊 Визуализация Sequential:

Sequential

  ├── left: Cause<E>    ← первая причина (произошла раньше)

  └── right: Cause<E>   ← вторая причина (произошла позже)

6. Parallel — Параллельная композиция

Parallel объединяет две причины, произошедшие одновременно в параллельных вычислениях.


// Создание Parallel напрямую
const parCause = Cause.parallel(
  Cause.fail("Task A failed"),
  Cause.fail("Task B failed")
)

// Типичный сценарий — параллельное выполнение
const parallelFailure = Effect.all(
  [
    Effect.fail("Database query failed"),
    Effect.fail("API call failed"),
    Effect.die("Unexpected crash")
  ],
  { concurrency: "unbounded" }
)

Effect.runPromiseExit(parallelFailure).then(console.log)
/*
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: {
    _id: 'Cause',
    _tag: 'Parallel',
    left: { _id: 'Cause', _tag: 'Fail', failure: 'Database query failed' },
    right: {
      _id: 'Cause',
      _tag: 'Parallel',
      left: { _id: 'Cause', _tag: 'Fail', failure: 'API call failed' },
      right: { _id: 'Cause', _tag: 'Die', defect: 'Unexpected crash' }
    }
  }
}
*/

📊 Визуализация Parallel:

Parallel

  ├── left: Cause<E>    ← причина из одного параллельного потока

  └── right: Cause<E>   ← причина из другого параллельного потока

API Reference

Конструкторы

// Пустая причина
Cause.empty: Cause<never>

// Ожидаемая ошибка
Cause.fail<E>(error: E): Cause<E>

// Неожиданный дефект
Cause.die(defect: unknown): Cause<never>

// Прерывание fiber
Cause.interrupt(fiberId: FiberId): Cause<never>

// Последовательная композиция
Cause.sequential<E>(left: Cause<E>, right: Cause<E>): Cause<E>

// Параллельная композиция
Cause.parallel<E>(left: Cause<E>, right: Cause<E>): Cause<E>

Type Guards (проверки типа)

// Проверка на пустоту
Cause.isEmpty<E>(cause: Cause<E>): boolean

// Проверка на Fail
Cause.isFailType<E>(cause: Cause<E>): cause is Cause.Fail<E>

// Проверка на Die
Cause.isDie(cause: Cause<E>): cause is Cause.Die

// Проверка на Interrupt
Cause.isInterruptType(cause: Cause<E>): cause is Cause.Interrupt

// Проверка на Sequential
Cause.isSequentialType<E>(cause: Cause<E>): cause is Cause.Sequential<E>

// Проверка на Parallel
Cause.isParallelType<E>(cause: Cause<E>): cause is Cause.Parallel<E>

Деструкторы и извлечение данных

// Извлечение первой ошибки Fail (если есть)
Cause.failureOption<E>(cause: Cause<E>): Option<E>

// Извлечение первого дефекта Die (если есть)
Cause.dieOption(cause: Cause<E>): Option<unknown>

// Извлечение FiberId прерывания (если есть)
Cause.interruptOption(cause: Cause<E>): Option<FiberId>

// Сбор всех ошибок Fail в Chunk
Cause.failures<E>(cause: Cause<E>): Chunk<E>

// Сбор всех дефектов Die в Chunk
Cause.defects(cause: Cause<E>): Chunk<unknown>

// Сбор всех прерванных FiberId в HashSet
Cause.interruptors(cause: Cause<E>): HashSet<FiberId>

Трансформации

// Преобразование ошибок Fail
Cause.map<E, E2>(cause: Cause<E>, f: (e: E) => E2): Cause<E2>

// flatMap для Cause
Cause.flatMap<E, E2>(cause: Cause<E>, f: (e: E) => Cause<E2>): Cause<E2>

// Фильтрация причин
Cause.filter<E>(cause: Cause<E>, predicate: (cause: Cause<E>) => boolean): Cause<E>

// Удаление пустых причин и упрощение структуры
Cause.squash<E>(cause: Cause<E>): unknown

Pattern Matching

Cause.match<E, Z>(cause: Cause<E>, options: {
  readonly onEmpty: Z
  readonly onFail: (error: E) => Z
  readonly onDie: (defect: unknown) => Z
  readonly onInterrupt: (fiberId: FiberId) => Z
  readonly onSequential: (left: Z, right: Z) => Z
  readonly onParallel: (left: Z, right: Z) => Z
}): Z

Pretty Printing

// Человекочитаемое представление
Cause.pretty<E>(cause: Cause<E>): string

Примеры

Пример 1: Анализ структуры Cause


// Создаём сложную причину
const complexCause = Cause.parallel(
  Cause.sequential(
    Cause.fail("Validation failed"),
    Cause.die("Serialization error")
  ),
  Cause.fail("Network timeout")
)

// Анализируем структуру
const analyzeResult = Cause.match(complexCause, {
  onEmpty: () => "No error",
  onFail: (error) => `Expected error: ${error}`,
  onDie: (defect) => `Defect: ${defect}`,
  onInterrupt: (fiberId) => `Interrupted: ${fiberId}`,
  onSequential: (left, right) => `Sequential(${left}, ${right})`,
  onParallel: (left, right) => `Parallel(${left}, ${right})`
})

console.log(analyzeResult)
// Parallel(Sequential(Expected error: Validation failed, Defect: Serialization error), Expected error: Network timeout)

// Извлекаем все ошибки
const allFailures = Cause.failures(complexCause)
console.log([...allFailures]) 
// ["Validation failed", "Network timeout"]

// Извлекаем все дефекты
const allDefects = Cause.defects(complexCause)
console.log([...allDefects])
// ["Serialization error"]

Пример 2: Получение Cause из Effect


// Программа, которая может упасть
const riskyProgram = Effect.gen(function* () {
  const random = Math.random()
  if (random < 0.33) {
    yield* Effect.fail("Expected failure")
  } else if (random < 0.66) {
    yield* Effect.die("Unexpected defect")
  }
  return "Success!"
})

// Получаем Cause для анализа
const programWithCause = Effect.gen(function* () {
  const cause = yield* Effect.cause(riskyProgram)
  
  if (Cause.isEmpty(cause)) {
    console.log("No errors occurred")
  } else if (Cause.isFailType(cause)) {
    console.log(`Expected error: ${cause.error}`)
  } else if (Cause.isDie(cause)) {
    console.log(`Defect: ${cause.defect}`)
  }
  
  return cause
})

Effect.runPromise(programWithCause)

Пример 3: Трансформация ошибок в Cause


// Типизированные ошибки
class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly field: string
  readonly message: string
}> {}

class ApiError extends Data.TaggedError("ApiError")<{
  readonly code: number
  readonly details: string
}> {}

type AppError = ValidationError | ApiError

// Причина с ошибками
const originalCause: Cause.Cause<AppError> = Cause.parallel(
  Cause.fail(new ValidationError({ field: "email", message: "Invalid format" })),
  Cause.fail(new ApiError({ code: 500, details: "Server error" }))
)

// Трансформируем в строковые ошибки для логирования
const stringCause = Cause.map(originalCause, (error) => {
  switch (error._tag) {
    case "ValidationError":
      return `Validation: ${error.field} - ${error.message}`
    case "ApiError":
      return `API ${error.code}: ${error.details}`
  }
})

console.log(Cause.pretty(stringCause))

Пример 4: Обработка параллельных ошибок в реальном сценарии


// Домен ошибок
class DbError extends Data.TaggedError("DbError")<{
  readonly query: string
}> {}

class CacheError extends Data.TaggedError("CacheError")<{
  readonly key: string
}> {}

class QueueError extends Data.TaggedError("QueueError")<{
  readonly queueName: string
}> {}

type InfraError = DbError | CacheError | QueueError

// Симуляция параллельных операций
const healthCheck = Effect.all(
  [
    Effect.fail(new DbError({ query: "SELECT 1" })),
    Effect.fail(new CacheError({ key: "health" })),
    Effect.fail(new QueueError({ queueName: "events" }))
  ],
  { concurrency: "unbounded" }
)

// Анализ всех ошибок
const analyzeHealth = Effect.gen(function* () {
  const cause = yield* Effect.cause(healthCheck)
  const failures = Cause.failures(cause)
  
  const report = Array.map([...failures], (error) => {
    switch (error._tag) {
      case "DbError":
        return `❌ Database: Query "${error.query}" failed`
      case "CacheError":
        return `❌ Cache: Key "${error.key}" unreachable`
      case "QueueError":
        return `❌ Queue: "${error.queueName}" unavailable`
    }
  })
  
  return report.join("\n")
})

Effect.runPromise(analyzeHealth).then(console.log)
/*
❌ Database: Query "SELECT 1" failed
❌ Cache: Key "health" unreachable
❌ Queue: "events" unavailable
*/

Пример 5: Создание Effect из Cause


// Иногда нужно создать Effect напрямую из Cause
// Это полезно при восстановлении из сохранённых ошибок

// Effect, который завершается с конкретным Cause
const fromCause = <E>(cause: Cause.Cause<E>): Effect.Effect<never, E> =>
  Effect.failCause(cause)

// Пример: перезапуск с сохранённой ошибкой
const savedCause = Cause.fail("Saved error from previous run")
const replayedError = fromCause(savedCause)

Effect.runPromiseExit(replayedError).then(console.log)
/*
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: { _id: 'Cause', _tag: 'Fail', failure: 'Saved error from previous run' }
}
*/

Упражнения

Упражнение

Создание разных типов Cause

Легко

Создайте функции, которые возвращают разные типы Cause:


// TODO: Реализуйте функции

// 1. Создать Fail с ошибкой "User not found"
const createFailCause = (): Cause.Cause<string> => {
  // Ваш код
}

// 2. Создать Die с Error("Unexpected null")
const createDieCause = (): Cause.Cause<never> => {
  // Ваш код
}

// 3. Создать Sequential из двух Fail
const createSequentialCause = (): Cause.Cause<string> => {
  // Ваш код
}

// 4. Создать Parallel из Fail и Die
const createParallelCause = (): Cause.Cause<string> => {
  // Ваш код
}
Упражнение

Type Guards

Легко

Упражнение 2: Type Guards

Напишите функцию, которая классифицирует тип Cause:


type CauseType =
  | "empty"
  | "fail"
  | "die"
  | "interrupt"
  | "sequential"
  | "parallel"

// TODO: Реализуйте функцию
const classifyCause = <E>(cause: Cause.Cause<E>): CauseType => {
  // Ваш код
}

// Тесты
console.log(classifyCause(Cause.empty)) // "empty"
console.log(classifyCause(Cause.fail("err"))) // "fail"
console.log(classifyCause(Cause.die("defect"))) // "die"
Упражнение

Извлечение информации из Cause

Средне

Напишите функцию для создания отчёта об ошибках из Cause:


// Домен ошибок
class UserError extends Data.TaggedError("UserError")<{
  readonly userId: string
  readonly reason: string
}> {}

class PaymentError extends Data.TaggedError("PaymentError")<{
  readonly amount: number
  readonly currency: string
}> {}

type BusinessError = UserError | PaymentError

interface ErrorReport {
  readonly expectedErrors: ReadonlyArray<string>
  readonly defects: ReadonlyArray<string>
  readonly wasInterrupted: boolean
  readonly totalIssues: number
}

// TODO: Реализуйте функцию
const createErrorReport = (
  cause: Cause.Cause<BusinessError>
): ErrorReport => {
  // Ваш код
}
Упражнение

Pattern Matching для логирования

Средне

Используйте Cause.match для создания структурированного лога:


interface LogEntry {
  readonly level: "error" | "fatal" | "warn"
  readonly message: string
  readonly context: Record<string, unknown>
}

// TODO: Реализуйте функцию
const causeToLogEntries = <E>(
  cause: Cause.Cause<E>,
  errorToString: (e: E) => string
): ReadonlyArray<LogEntry> => {
  // Ваш код
}

// Тест
const testCause = Cause.parallel(
  Cause.fail("Validation failed"),
  Cause.sequential(
    Cause.die(new Error("NPE")),
    Cause.fail("Cleanup failed")
  )
)

console.log(causeToLogEntries(testCause, String))
Упражнение

Сериализация и десериализация Cause

Сложно

Реализуйте функции для сериализации Cause в JSON и обратно (для передачи между процессами):


interface SerializedCause {
  readonly _tag: string
  readonly data?: unknown
  readonly left?: SerializedCause
  readonly right?: SerializedCause
}

// TODO: Реализуйте функции
const serializeCause = <E>(
  cause: Cause.Cause<E>,
  serializeError: (e: E) => unknown
): SerializedCause => {
  // Ваш код
}

const deserializeCause = <E>(
  serialized: SerializedCause,
  deserializeError: (data: unknown) => E
): Cause.Cause<E> => {
  // Ваш код
}
Упражнение

Агрегация ошибок для отчёта

Сложно

Упражнение 6: Агрегация ошибок для отчёта

Создайте систему агрегации ошибок для batch-обработки:


class ItemError extends Data.TaggedError("ItemError")<{
  readonly itemId: string
  readonly reason: string
}> {}

interface BatchResult<A> {
  readonly successful: ReadonlyArray<A>
  readonly failed: ReadonlyArray<{ itemId: string; error: string }>
  readonly defects: ReadonlyArray<string>
  readonly summary: string
}

// TODO: Реализуйте функцию
// Принимает список Effect, выполняет их параллельно,
// собирает успехи и все ошибки в единый отчёт
const processBatch = <A>(
  items: ReadonlyArray<Effect.Effect<A, ItemError>>
): Effect.Effect<BatchResult<A>> => {
  // Ваш код
}

Резюме

Тип CauseНазначениеВлияние на тип Effect
EmptyНет ошибки
Fail<E>Ожидаемая ошибкаТипизирован в E
DieНеожиданный дефектНе влияет (never)
InterruptПрерывание fiberНе влияет (never)
SequentialЦепочка ошибокОбъединяет типы
ParallelПараллельные ошибкиОбъединяет типы

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

  • Cause — это дерево причин, позволяющее представить любую комбинацию ошибок
  • Используйте Cause.failures и Cause.defects для извлечения списков ошибок
  • Cause.match — основной инструмент для pattern matching по всем вариантам
  • Cause.pretty — для человекочитаемого вывода при отладке