Типобезопасный домен: Гексагональная архитектура на базе Effect Композиция ошибок: Effect.mapError, catchTag, catchAll
Глава

Композиция ошибок: Effect.mapError, catchTag, catchAll

Автоматическое объединение ошибок в union. Полный справочник операторов: mapError, mapBoth, catchTag, catchTags, catchAll, catchSome, orElse, orElseSucceed, orElseFail, orDie, tapError, tapErrorTag, retry. Практические паттерны: Adapter Error Boundary, Use Case Error Pipeline, HTTP Error Handler, Error Enrichment, Graceful Degradation.

Введение: зачем нужна композиция ошибок

В реальном приложении бизнес-операция — это цепочка шагов. Каждый шаг может завершиться своей ошибкой. При композиции шагов ошибки автоматически объединяются в union. Но на каком-то этапе нужно:

  • Обработать конкретную ошибку и продолжить выполнение
  • Трансформировать инфраструктурную ошибку в доменную
  • Отфильтровать ошибку и заменить значением по умолчанию
  • Обогатить ошибку дополнительным контекстом
  • Сужать union ошибок по мере их обработки

Effect предоставляет полный набор операторов для каждого из этих сценариев.


Автоматическое объединение ошибок

Как Effect собирает union

При последовательной композиции (pipe, flatMap, tap) TypeScript автоматически объединяет типы ошибок:

import { Effect, pipe } from "effect"

declare const step1: Effect.Effect<string, TodoNotFound>
declare const step2: (s: string) => Effect.Effect<number, InvalidStatusTransition>
declare const step3: (n: number) => Effect.Effect<boolean, DuplicateTodoTitle>

const pipeline = pipe(
  step1,                        // E = TodoNotFound
  Effect.flatMap(step2),        // E = TodoNotFound | InvalidStatusTransition
  Effect.flatMap(step3),        // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle
)
// Итоговый тип:
// Effect<boolean, TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle>

Union растёт при каждом шаге

// Визуализация роста union
const program = pipe(
  findTodo(id),
  // E = TodoNotFound
  
  Effect.flatMap(todo => validateTransition(todo, "Completed")),
  // E = TodoNotFound | InvalidStatusTransition
  
  Effect.flatMap(todo => checkDuplicate(todo.title)),
  // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle
  
  Effect.flatMap(() => saveTodo(todo)),
  // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle | DatabaseError
  
  Effect.flatMap(() => sendNotification(todo)),
  // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle | DatabaseError | NotificationError
)

Дедупликация в union

TypeScript автоматически дедуплицирует типы в union:

declare const a: Effect.Effect<string, TodoNotFound>
declare const b: Effect.Effect<number, TodoNotFound>

const combined = pipe(
  a,
  Effect.flatMap(() => b),
)
// E = TodoNotFound (не TodoNotFound | TodoNotFound)

Effect.mapError — трансформация ошибки

Базовое использование

mapError применяет функцию к ошибке, изменяя её тип:

import { Effect, pipe } from "effect"

// Трансформация одной ошибки в другую
const program = pipe(
  databaseQuery("SELECT * FROM todos WHERE id = ?", [id]),
  // E = DatabaseError
  
  Effect.mapError((dbError) => new TodoNotFound({ todoId: id })),
  // E = TodoNotFound
)

mapError с сохранением контекста

// Создаём ошибку-обёртку с оригинальной причиной
class RepositoryError extends Data.TaggedError("RepositoryError")<{
  readonly operation: string
  readonly cause: unknown
}> {}

const program = pipe(
  databaseQuery(sql, params),
  Effect.mapError((dbError) =>
    new RepositoryError({
      operation: "findById",
      cause: dbError,
    })
  ),
)

mapError для сужения union

// Превращаем все ошибки в один тип для API-ответа
class ApiError extends Data.TaggedError("ApiError")<{
  readonly code: string
  readonly message: string
  readonly statusCode: number
}> {}

const apiProgram = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle
  
  Effect.mapError((error): ApiError => {
    switch (error._tag) {
      case "TodoNotFound":
        return new ApiError({
          code: "NOT_FOUND",
          message: `Todo ${error.todoId} not found`,
          statusCode: 404,
        })
      case "InvalidStatusTransition":
        return new ApiError({
          code: "CONFLICT",
          message: `Cannot transition from ${error.currentStatus} to ${error.targetStatus}`,
          statusCode: 409,
        })
      case "DuplicateTodoTitle":
        return new ApiError({
          code: "CONFLICT",
          message: `Todo with title "${error.title}" already exists`,
          statusCode: 409,
        })
    }
  }),
  // E = ApiError
)

Effect.catchTag — перехват конкретной ошибки

Базовое использование

catchTag перехватывает ошибку по её _tag и обрабатывает:

import { Effect, pipe } from "effect"

const program = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition
  
  Effect.catchTag("TodoNotFound", (error) =>
    // error типизирован как TodoNotFound
    Effect.succeed(createDefaultTodo(error.todoId))
  ),
  // E = InvalidStatusTransition
  // ^^^^^^^^^^^^^^^^^^^^^^^^^ — TodoNotFound обработана!
)

Ключевой момент: catchTag сужает union — обработанная ошибка исчезает из типа.

catchTag с возвратом другой ошибки

const program = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition
  
  Effect.catchTag("TodoNotFound", (error) =>
    Effect.fail(new UserFacingError({
      message: `Task not found. Please refresh the page.`,
    }))
  ),
  // E = InvalidStatusTransition | UserFacingError
  //     TodoNotFound заменена на UserFacingError
)

Цепочка catchTag

const program = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle
  
  Effect.catchTag("TodoNotFound", (error) =>
    Effect.succeed(createDefaultTodo(error.todoId))
  ),
  // E = InvalidStatusTransition | DuplicateTodoTitle
  
  Effect.catchTag("InvalidStatusTransition", (error) =>
    Effect.logWarning(`Invalid transition: ${error.currentStatus} → ${error.targetStatus}`).pipe(
      Effect.flatMap(() => Effect.succeed(null))
    )
  ),
  // E = DuplicateTodoTitle
  
  Effect.catchTag("DuplicateTodoTitle", (error) =>
    Effect.fail(new UserFacingError({ message: `Title "${error.title}" already taken` }))
  ),
  // E = UserFacingError
)

Effect.catchTags — одновременная обработка нескольких ошибок

Базовое использование

catchTags принимает объект с обработчиками для каждого тега:

import { Effect, pipe } from "effect"

const program = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle
  
  Effect.catchTags({
    TodoNotFound: (error) =>
      Effect.succeed(createDefaultTodo(error.todoId)),
    
    InvalidStatusTransition: (error) =>
      Effect.fail(new UserFacingError({
        message: `Cannot complete: task is ${error.currentStatus}`,
      })),
    
    DuplicateTodoTitle: (error) =>
      Effect.fail(new UserFacingError({
        message: `Title "${error.title}" already exists`,
      })),
  }),
  // E = UserFacingError
)

catchTags с exhaustiveness checking

Если обработаны все теги, E-канал становится never:

const program = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition
  
  Effect.catchTags({
    TodoNotFound: (_) => Effect.succeed(null),
    InvalidStatusTransition: (_) => Effect.succeed(null),
  }),
  // E = never — все ошибки обработаны!
)

Частичная обработка через catchTags

Можно обработать только часть ошибок:

const program = pipe(
  complexOperation(),
  // E = TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle | RateLimitExceeded
  
  Effect.catchTags({
    TodoNotFound: (error) => Effect.succeed(null),
    DuplicateTodoTitle: (error) => Effect.succeed(null),
    // InvalidStatusTransition и RateLimitExceeded НЕ обработаны
  }),
  // E = InvalidStatusTransition | RateLimitExceeded
)

Effect.catchAll — обработка всех ошибок

Базовое использование

catchAll перехватывает любую ошибку:

const program = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition
  
  Effect.catchAll((error) => {
    // error: TodoNotFound | InvalidStatusTransition
    return Effect.succeed({
      success: false,
      error: error._tag,
      message: error.message,
    })
  }),
  // E = never — все ошибки обработаны
)

catchAll с новой ошибкой

const program = pipe(
  completeTodo(id),
  Effect.catchAll((error) =>
    Effect.fail(new ApiError({
      code: error._tag,
      message: error.message,
      statusCode: error._tag === "TodoNotFound" ? 404 : 409,
    }))
  ),
  // E = ApiError
)

Effect.orElse и fallback-паттерны

orElse — альтернативный эффект при ошибке

const program = pipe(
  // Попробовать найти в кеше
  findTodoInCache(id),
  // Если ошибка — попробовать найти в БД
  Effect.orElse(() => findTodoInDatabase(id)),
)

orElseSucceed — значение по умолчанию

// При любой ошибке вернуть значение по умолчанию
const program = pipe(
  findTodo(id),
  Effect.orElseSucceed(() => createDefaultTodo()),
)
// E = never — ошибок нет, всегда возвращается Todo

orElseFail — замена ошибки

// Заменить ВСЕ ошибки на одну
const program = pipe(
  complexOperation(),
  Effect.orElseFail(() => new GeneralError({ message: "Operation failed" })),
)
// E = GeneralError

orDie — ошибка → дефект

// Если ошибка невозможна в нормальных условиях
const program = pipe(
  configLoad(),
  Effect.orDie, // ConfigError → Defect (баг)
)
// E = never — ошибок нет, но при ошибке будет Defect

Effect.tapError — побочный эффект при ошибке

tapError выполняет побочный эффект (например, логирование) при ошибке, не изменяя ни ошибку, ни тип:

const program = pipe(
  completeTodo(id),
  // E = TodoNotFound | InvalidStatusTransition
  
  Effect.tapError((error) =>
    Effect.log(`Operation failed: ${error._tag}`, {
      level: "warn",
      todoId: id,
      errorTag: error._tag,
    })
  ),
  // E = TodoNotFound | InvalidStatusTransition (тип НЕ изменился)
)

tapError с pattern matching

const program = pipe(
  completeTodo(id),
  Effect.tapError((error) => {
    switch (error._tag) {
      case "TodoNotFound":
        return Effect.logWarning("Todo not found", { todoId: error.todoId })
      case "InvalidStatusTransition":
        return Effect.logError("Invalid transition", {
          todoId: error.todoId,
          from: error.currentStatus,
          to: error.targetStatus,
        })
    }
  }),
)

tapErrorTag — побочный эффект для конкретной ошибки

const program = pipe(
  completeTodo(id),
  Effect.tapErrorTag("TodoNotFound", (error) =>
    Effect.logWarning("Todo not found", { todoId: error.todoId })
  ),
  Effect.tapErrorTag("InvalidStatusTransition", (error) =>
    Effect.logError("Invalid transition attempted")
  ),
)

Effect.mapBoth — одновременная трансформация

mapBoth трансформирует и значение, и ошибку одновременно:

const program = pipe(
  databaseQuery(`SELECT * FROM todos WHERE id = ?`, [id]),
  // A = Row, E = DatabaseError
  
  Effect.mapBoth({
    onSuccess: rowToTodo,        // Row → Todo
    onFailure: () => new TodoNotFound({ todoId: id }), // DatabaseError → TodoNotFound
  }),
  // A = Todo, E = TodoNotFound
)

Effect.catchSome — условная обработка ошибок

catchSome позволяет обработать ошибку только если она соответствует условию, возвращая Option:

import { Effect, Option, pipe } from "effect"

const program = pipe(
  complexOperation(),
  // E = TodoNotFound | InvalidStatusTransition | DatabaseError
  
  Effect.catchSome((error) => {
    if (error._tag === "TodoNotFound") {
      return Option.some(
        Effect.succeed(createDefaultTodo(error.todoId))
      )
    }
    return Option.none() // Не обрабатываем — пропускаем
  }),
  // E = InvalidStatusTransition | DatabaseError
)

Effect.retry — повтор при ошибке

Базовый retry

import { Effect, Schedule, pipe } from "effect"

// Повторить 3 раза с экспоненциальной задержкой
const program = pipe(
  saveTodo(todo),
  Effect.retry(
    Schedule.exponential("100 millis").pipe(
      Schedule.compose(Schedule.recurs(3))
    )
  ),
)

Retry только для определённых ошибок

import { Effect, Schedule, pipe } from "effect"

// Повторять только при DatabaseError, остальные — сразу fail
const program = pipe(
  saveTodo(todo),
  Effect.retry({
    schedule: Schedule.exponential("100 millis").pipe(
      Schedule.compose(Schedule.recurs(3))
    ),
    while: (error) => error._tag === "DatabaseError",
  }),
)

Retry с fallback

const program = pipe(
  fetchFromPrimary(),
  Effect.retry(Schedule.recurs(2)),
  Effect.orElse(() =>
    pipe(
      fetchFromSecondary(),
      Effect.retry(Schedule.recurs(2)),
    )
  ),
)

Практические паттерны композиции

Паттерн 1: Adapter Error Boundary

Полная трансформация инфраструктурных ошибок на границе адаптера:

// infrastructure/adapters/todo-repository-sqlite.ts

const findById = (id: TodoId): Effect.Effect<Todo, TodoNotFound> =>
  pipe(
    // SQLite запрос — может бросить любую DB-ошибку
    sqliteQuery(`SELECT * FROM todos WHERE id = ?`, [id]),
    
    // Трансформация: пустой результат → TodoNotFound
    Effect.flatMap(rows =>
      rows.length === 0
        ? Effect.fail(new TodoNotFound({ todoId: id }))
        : Effect.succeed(rowToTodo(rows[0]!))
    ),
    
    // Трансформация: любая DB-ошибка → TodoNotFound
    // (для findById отсутствие данных из-за ошибки = "не найдено")
    Effect.catchAll(() => Effect.fail(new TodoNotFound({ todoId: id }))),
  )

Паттерн 2: Use Case Error Pipeline

Обработка ошибок на уровне Application Layer:

// application/use-cases/complete-todo.ts

export const completeTodoUseCase = (
  command: CompleteTodoCommand,
): Effect.Effect<
  Todo,
  TodoNotFound | InvalidStatusTransition | UnauthorizedAccess,
  TodoRepository | AuthorizationService
> =>
  pipe(
    // Авторизация — добавляет UnauthorizedAccess
    checkAuthorization(command.userId, command.todoId),
    
    // Загрузка — добавляет TodoNotFound
    Effect.flatMap(() =>
      TodoRepository.pipe(
        Effect.flatMap(repo => repo.findById(command.todoId))
      )
    ),
    
    // Бизнес-логика — добавляет InvalidStatusTransition
    Effect.flatMap(todo => transitionStatus(todo, "Completed")),
    
    // Сохранение — DuplicateTodoTitle от save не ожидается при complete,
    // поэтому превращаем в Defect
    Effect.flatMap(todo =>
      TodoRepository.pipe(
        Effect.flatMap(repo => repo.save(todo)),
        Effect.catchTag("DuplicateTodoTitle", (error) => Effect.die(error)),
        Effect.map(() => todo),
      )
    ),
    
    // Логирование всех ошибок
    Effect.tapError(error =>
      Effect.logWarning("completeTodo failed", {
        command,
        error: error._tag,
      })
    ),
  )

Паттерн 3: HTTP Error Handler

Маппинг всех ошибок в HTTP-ответы на уровне Driving Adapter:

// infrastructure/adapters/http/routes.ts

const completeTodoRoute = pipe(
  // Парсинг запроса
  parseRequest(CompleteTodoSchema),
  
  // Вызов Use Case
  Effect.flatMap(command => completeTodoUseCase(command)),
  
  // Успех → 200
  Effect.map(todo => HttpResponse.json(todo, { status: 200 })),
  
  // Все ошибки → HTTP-ответы
  Effect.catchTags({
    TodoNotFound: (error) =>
      Effect.succeed(HttpResponse.json(
        { error: "NOT_FOUND", todoId: error.todoId },
        { status: 404 },
      )),
    
    InvalidStatusTransition: (error) =>
      Effect.succeed(HttpResponse.json(
        {
          error: "CONFLICT",
          message: `Cannot transition from ${error.currentStatus} to ${error.targetStatus}`,
          allowed: error.allowedTransitions,
        },
        { status: 409 },
      )),
    
    UnauthorizedAccess: (error) =>
      Effect.succeed(HttpResponse.json(
        { error: "FORBIDDEN", resource: error.resource },
        { status: 403 },
      )),
  }),
)

Паттерн 4: Error Enrichment — обогащение ошибки контекстом

const enrichWithRequestContext = <A, E extends { readonly _tag: string }, R>(
  effect: Effect.Effect<A, E, R>,
  requestId: string,
  userId: string,
) =>
  pipe(
    effect,
    Effect.tapError((error) =>
      Effect.logError("Request failed", {
        requestId,
        userId,
        errorTag: error._tag,
        errorMessage: error instanceof Error ? error.message : String(error),
      })
    ),
    Effect.mapError((error) => ({
      ...error,
      _requestId: requestId,
      _userId: userId,
    })),
  )

Паттерн 5: Graceful Degradation

const getTodoWithFallback = (id: TodoId) =>
  pipe(
    // Попытка 1: основной репозиторий
    TodoRepository.pipe(
      Effect.flatMap(repo => repo.findById(id))
    ),
    
    // Fallback: кеш (если основной недоступен)
    Effect.catchTag("TodoNotFound", (error) =>
      pipe(
        CacheRepository.pipe(
          Effect.flatMap(cache => cache.get(id))
        ),
        // Если и в кеше нет — возвращаем оригинальную ошибку
        Effect.catchAll(() => Effect.fail(error)),
      )
    ),
  )

Сводная таблица операторов

ОператорНазначениеВходной EВыходной E
mapErrorТрансформация всех ошибокEE2
mapBothТрансформация значения и ошибкиEE2
catchTagОбработка одной ошибки по тегуA | B | CB | C | D
catchTagsОбработка нескольких ошибок по тегамA | B | CD
catchAllОбработка всех ошибокEE2
catchSomeУсловная обработкаEE (может сузить)
orElseАльтернативный эффектEE2
orElseSucceedЗначение по умолчаниюEnever
orElseFailЗамена ошибкиEE2
orDieОшибка → дефектEnever
tapErrorПобочный эффект (без изменения)EE
tapErrorTagПобочный эффект для конкретного тегаEE
retryПовтор при ошибкеEE
catchTag + failЗамена одной ошибки другойA | BB | C

Антипаттерны

Антипаттерн 1: catchAll когда достаточно catchTag

// ❌ Ловим все — теряем типизацию
Effect.catchAll((error) => {
  if (error._tag === "TodoNotFound") {
    return Effect.succeed(null)
  }
  return Effect.fail(error) // error: unknown — потеряли тип!
})

// ✅ Ловим конкретный тег — тип сохранён
Effect.catchTag("TodoNotFound", () => Effect.succeed(null))

Антипаттерн 2: Проглатывание ошибок

// ❌ Все ошибки молча превращаются в null
Effect.catchAll(() => Effect.succeed(null))

// ✅ Хотя бы логируем
Effect.tapError(error => Effect.logError("Unexpected error", { error })).pipe(
  Effect.catchAll(() => Effect.succeed(null))
)

Антипаттерн 3: orDie для ожидаемых ошибок

// ❌ TodoNotFound — ожидаемая бизнес-ошибка!
pipe(findTodo(id), Effect.orDie)

// ✅ orDie только для настоящих дефектов
pipe(loadConfig(), Effect.orDie) // Отсутствие конфига = баг в deployment

Резюме

ПринципОписание
Автоматический unionEffect собирает все ошибки при композиции
catchTag сужает unionОбработанная ошибка исчезает из типа
mapError трансформируетЗамена одного типа ошибки на другой
tapError не изменяетПобочный эффект без трансформации
orDie для дефектовПревращение ошибки в Defect (скрывает из E)
catchTags для множестваОбработка нескольких тегов одновременно
retry с фильтромПовтор только для определённых ошибок