Композиция ошибок: 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 | Трансформация всех ошибок | E | E2 |
mapBoth | Трансформация значения и ошибки | E | E2 |
catchTag | Обработка одной ошибки по тегу | A | B | C | B | C | D |
catchTags | Обработка нескольких ошибок по тегам | A | B | C | D |
catchAll | Обработка всех ошибок | E | E2 |
catchSome | Условная обработка | E | E (может сузить) |
orElse | Альтернативный эффект | E | E2 |
orElseSucceed | Значение по умолчанию | E | never |
orElseFail | Замена ошибки | E | E2 |
orDie | Ошибка → дефект | E | never |
tapError | Побочный эффект (без изменения) | E | E |
tapErrorTag | Побочный эффект для конкретного тега | E | E |
retry | Повтор при ошибке | E | E |
catchTag + fail | Замена одной ошибки другой | A | B | B | 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
Резюме
| Принцип | Описание |
|---|---|
| Автоматический union | Effect собирает все ошибки при композиции |
| catchTag сужает union | Обработанная ошибка исчезает из типа |
| mapError трансформирует | Замена одного типа ошибки на другой |
| tapError не изменяет | Побочный эффект без трансформации |
| orDie для дефектов | Превращение ошибки в Defect (скрывает из E) |
| catchTags для множества | Обработка нескольких тегов одновременно |
| retry с фильтром | Повтор только для определённых ошибок |