Data.TaggedError: доменные ошибки как tagged unions
Синтаксис и механика Data.TaggedError. Tagged Union и exhaustiveness checking. Структурное равенство (Equal и Hash). Интеграция с Effect (fail, catchTag, mapError). Сравнение с class Error, Data.TaggedClass, Schema.TaggedError. Паттерны создания: smart constructors, группировка, вложенные причины.
Введение: почему Data.TaggedError?
В предыдущей статье мы установили, что доменные ошибки — это полноценные граждане бизнес-логики. Теперь нужен инструмент для их создания. Effect-ts предлагает Data.TaggedError — специализированный конструктор ошибок, который обеспечивает:
- Тег
_tag— уникальный дискриминатор для pattern matching - Структурное равенство — сравнение по значению, а не по ссылке
- Иммутабельность — все поля
readonly - Совместимость с
Error— наследует отError, работает сcauseиstack - Интеграцию с Effect — бесшовно работает с
Effect.fail,catchTag,mapError
Основы Data.TaggedError
Базовый синтаксис
Data.TaggedError — это фабричная функция, которая принимает строковый тег и возвращает базовый класс для наследования:
import { Data } from "effect"
// Определение доменной ошибки
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly todoId: string
}> {}
Разберём синтаксис по частям:
class TodoNotFound // 1. Имя класса ошибки
extends Data.TaggedError // 2. Базовый класс из Effect
("TodoNotFound") // 3. Строковый тег (дискриминатор)
<{ // 4. Generic параметр — shape данных
readonly todoId: string // 5. Поля ошибки (readonly!)
}>
{} // 6. Пустое тело — логика не нужна
Что генерирует Data.TaggedError
Под капотом Data.TaggedError("TodoNotFound") создаёт класс со следующими свойствами:
// Это примерный эквивалент того, что генерируется
class TodoNotFound extends Error {
readonly _tag = "TodoNotFound" as const // Дискриминатор
readonly todoId: string // Поля из generic параметра
// Структурное равенство (Equal trait)
[Equal.symbol](that: unknown): boolean { ... }
// Хеш для коллекций (Hash trait)
[Hash.symbol](): number { ... }
// toString с тегом и данными
toString(): string { ... }
// message из Error
get message(): string { ... }
// stack trace из Error
get stack(): string | undefined { ... }
}
Создание экземпляра ошибки
// Конструктор принимает объект с полями
const error = new TodoNotFound({ todoId: "abc-123" })
console.log(error._tag) // "TodoNotFound"
console.log(error.todoId) // "abc-123"
console.log(error.message) // "TodoNotFound: { todoId: 'abc-123' }"
console.log(error instanceof Error) // true
Tagged Union — дискриминированное объединение
Что такое Tagged Union
Tagged Union (он же Discriminated Union) — это техника TypeScript, позволяющая создавать типобезопасные объединения типов с возможностью различения по тегу:
// Каждый тип имеет уникальный _tag
type TodoError =
| TodoNotFound // _tag: "TodoNotFound"
| InvalidStatusTransition // _tag: "InvalidStatusTransition"
| DuplicateTodoTitle // _tag: "DuplicateTodoTitle"
// TypeScript может сузить тип по _tag
const handleError = (error: TodoError): string => {
switch (error._tag) {
case "TodoNotFound":
// TypeScript знает: error — это TodoNotFound
return `Задача ${error.todoId} не найдена`
case "InvalidStatusTransition":
// TypeScript знает: error — это InvalidStatusTransition
return `Невозможно перейти из ${error.currentStatus} в ${error.targetStatus}`
case "DuplicateTodoTitle":
// TypeScript знает: error — это DuplicateTodoTitle
return `Задача с заголовком "${error.title}" уже существует`
}
}
Exhaustiveness Checking
Критически важное свойство Tagged Union — компилятор может проверить, что все варианты обработаны:
// Если добавить новую ошибку в union, но забыть обработать —
// TypeScript покажет ошибку компиляции
type TodoError =
| TodoNotFound
| InvalidStatusTransition
| DuplicateTodoTitle
| TodoListFull // ← Добавили новую ошибку
const handleError = (error: TodoError): string => {
switch (error._tag) {
case "TodoNotFound":
return `Задача не найдена`
case "InvalidStatusTransition":
return `Невозможный переход`
case "DuplicateTodoTitle":
return `Дублирующийся заголовок`
// ❌ Ошибка компиляции!
// 'TodoListFull' is not assignable to type 'never'
// TypeScript требует обработать все варианты
}
}
Для обеспечения exhaustiveness checking используйте вспомогательную функцию:
// Утилита для exhaustive switch
const absurd = (value: never): never => {
throw new Error(`BUG: Unexpected value: ${JSON.stringify(value)}`)
}
const handleError = (error: TodoError): string => {
switch (error._tag) {
case "TodoNotFound":
return `Задача не найдена`
case "InvalidStatusTransition":
return `Невозможный переход`
case "DuplicateTodoTitle":
return `Дублирующийся заголовок`
case "TodoListFull":
return `Список задач заполнен`
default:
return absurd(error) // ← Гарантия полноты
}
}
Полный каталог доменных ошибок: пошаговое создание
Рассмотрим создание полного набора ошибок для Todo-домена.
Ошибка без дополнительных данных
Иногда ошибке достаточно только тега:
import { Data } from "effect"
// Ошибка без полей — только факт
class TodoListFull extends Data.TaggedError("TodoListFull")<{}> {}
const error = new TodoListFull()
console.log(error._tag) // "TodoListFull"
console.log(error.message) // "TodoListFull"
Замечание: Даже для ошибок без данных рекомендуется добавлять контекст — в какой именно TodoList было полно? Но для очень простых случаев пустая ошибка допустима.
Ошибка с одним полем
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly todoId: TodoId
}> {}
const error = new TodoNotFound({ todoId: TodoId.make("abc-123") })
console.log(error.todoId) // TodoId("abc-123")
Ошибка с несколькими полями
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
readonly todoId: TodoId
readonly currentStatus: TodoStatus
readonly targetStatus: TodoStatus
}> {}
const error = new InvalidStatusTransition({
todoId: TodoId.make("abc-123"),
currentStatus: "Completed",
targetStatus: "Completed",
})
Ошибка с вычисляемым сообщением
По умолчанию Data.TaggedError генерирует сообщение из тега и данных. Но вы можете переопределить message:
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
readonly todoId: TodoId
readonly currentStatus: TodoStatus
readonly targetStatus: TodoStatus
}> {
get message(): string {
return (
`Cannot transition todo ${this.todoId} ` +
`from "${this.currentStatus}" to "${this.targetStatus}"`
)
}
}
const error = new InvalidStatusTransition({
todoId: TodoId.make("abc-123"),
currentStatus: "Completed",
targetStatus: "Active",
})
console.log(error.message)
// "Cannot transition todo abc-123 from "Completed" to "Active""
Ошибка с массивом допустимых переходов
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
readonly todoId: TodoId
readonly currentStatus: TodoStatus
readonly targetStatus: TodoStatus
readonly allowedTransitions: ReadonlyArray<TodoStatus>
}> {
get message(): string {
const allowed = this.allowedTransitions.join(", ")
return (
`Cannot transition todo ${this.todoId} ` +
`from "${this.currentStatus}" to "${this.targetStatus}". ` +
`Allowed transitions: [${allowed}]`
)
}
}
const error = new InvalidStatusTransition({
todoId: TodoId.make("abc-123"),
currentStatus: "Completed",
targetStatus: "Active",
allowedTransitions: ["Archived"] as const,
})
Ошибка с опциональными полями
class DuplicateTodoTitle extends Data.TaggedError("DuplicateTodoTitle")<{
readonly title: string
readonly existingTodoId: TodoId | undefined
}> {}
// existingTodoId может отсутствовать, если мы не хотим
// загружать существующую задачу при проверке
const error = new DuplicateTodoTitle({
title: "Buy groceries",
existingTodoId: undefined,
})
Однако предпочтительнее использовать Option из Effect:
import { Option } from "effect"
class DuplicateTodoTitle extends Data.TaggedError("DuplicateTodoTitle")<{
readonly title: string
readonly existingTodoId: Option.Option<TodoId>
}> {}
const error = new DuplicateTodoTitle({
title: "Buy groceries",
existingTodoId: Option.none(),
})
const errorWithId = new DuplicateTodoTitle({
title: "Buy groceries",
existingTodoId: Option.some(TodoId.make("existing-123")),
})
Структурное равенство (Equal и Hash)
Data.TaggedError автоматически реализует два трейта из Effect: Equal и Hash.
Equal — сравнение по значению
import { Equal } from "effect"
const error1 = new TodoNotFound({ todoId: TodoId.make("abc-123") })
const error2 = new TodoNotFound({ todoId: TodoId.make("abc-123") })
const error3 = new TodoNotFound({ todoId: TodoId.make("def-456") })
// Структурное равенство — одинаковые данные = одинаковые ошибки
Equal.equals(error1, error2) // true
Equal.equals(error1, error3) // false
// Обычное сравнение ссылок НЕ работает
error1 === error2 // false (разные объекты)
error1 == error2 // false (разные объекты)
Hash — для коллекций
Реализация Hash позволяет использовать ошибки как ключи в HashMap и HashSet:
import { HashMap, HashSet } from "effect"
// Можно собирать уникальные ошибки
const errorSet = HashSet.make(
new TodoNotFound({ todoId: TodoId.make("abc-123") }),
new TodoNotFound({ todoId: TodoId.make("abc-123") }), // дупликат
new TodoNotFound({ todoId: TodoId.make("def-456") }),
)
HashSet.size(errorSet) // 2 (дупликат отброшен)
Почему структурное равенство важно для тестов
import { Effect, Exit } from "effect"
// В тестах можно точно проверять ошибки
const result = await Effect.runPromiseExit(
findTodo(TodoId.make("non-existent")).pipe(
Effect.provide(TestLayer),
)
)
// Сравнение через Exit.fail — использует Equal под капотом
expect(result).toEqual(
Exit.fail(new TodoNotFound({ todoId: TodoId.make("non-existent") }))
)
Использование с Effect
Effect.fail — создание ошибочного эффекта
import { Effect } from "effect"
// Создаём эффект, который всегда завершается ошибкой
const notFound: Effect.Effect<never, TodoNotFound> =
Effect.fail(new TodoNotFound({ todoId: TodoId.make("abc-123") }))
// TypeScript выводит тип ошибки автоматически
// Effect.Effect<never, TodoNotFound>
Использование в бизнес-логике
import { Effect, pipe, Option } from "effect"
const completeTodo = (id: TodoId): Effect.Effect<
Todo,
TodoNotFound | InvalidStatusTransition,
TodoRepository
> =>
pipe(
// Шаг 1: найти задачу
TodoRepository.pipe(
Effect.flatMap(repo => repo.findById(id)),
),
// Шаг 2: проверить возможность перехода
Effect.flatMap(todo => {
if (todo.status === "Completed") {
return Effect.fail(new InvalidStatusTransition({
todoId: id,
currentStatus: todo.status,
targetStatus: "Completed",
allowedTransitions: [],
}))
}
if (todo.status === "Archived") {
return Effect.fail(new InvalidStatusTransition({
todoId: id,
currentStatus: todo.status,
targetStatus: "Completed",
allowedTransitions: ["Active"],
}))
}
return Effect.succeed({ ...todo, status: "Completed" as const })
}),
)
catchTag — обработка конкретной ошибки
import { Effect } from "effect"
const program = pipe(
completeTodo(id),
// Обработать только TodoNotFound, остальные пропустить
Effect.catchTag("TodoNotFound", (error) =>
Effect.succeed(createDefaultTodo(error.todoId))
),
)
// Тип: Effect<Todo, InvalidStatusTransition, TodoRepository>
// ^^^^^^^^^^^^^^^^^^^^^^^^^
// TodoNotFound обработана, осталась только эта
catchTags — обработка нескольких ошибок одновременно
const program = pipe(
completeTodo(id),
Effect.catchTags({
TodoNotFound: (error) =>
Effect.succeed(createDefaultTodo(error.todoId)),
InvalidStatusTransition: (error) =>
Effect.fail(new UserFacingError({
message: `Задачу нельзя завершить: она уже ${error.currentStatus}`,
})),
}),
)
// Тип: Effect<Todo, UserFacingError, TodoRepository>
mapError — трансформация ошибки
const program = pipe(
completeTodo(id),
// Трансформировать все ошибки в единый тип для API
Effect.mapError(error => new ApiError({
code: error._tag,
message: error.message,
details: error,
})),
)
// Тип: Effect<Todo, ApiError, TodoRepository>
Data.TaggedError vs другие подходы
Сравнение с обычным class Error
// ❌ Обычный class Error
class TodoNotFoundError extends Error {
constructor(readonly todoId: string) {
super(`Todo ${todoId} not found`)
this.name = "TodoNotFoundError"
}
}
// Проблемы:
// 1. Нет _tag дискриминатора для pattern matching
// 2. Нет структурного равенства (только ===)
// 3. Мутабельные поля (message, stack)
// 4. Нет Hash для коллекций
// 5. Сравнение через instanceof — хрупкое
const e1 = new TodoNotFoundError("abc")
const e2 = new TodoNotFoundError("abc")
e1 === e2 // false — нет структурного равенства
Сравнение с Data.TaggedClass
import { Data } from "effect"
// Data.TaggedClass — для обычных данных (не ошибок)
class TodoNotFoundData extends Data.TaggedClass("TodoNotFound")<{
readonly todoId: TodoId
}> {}
// Data.TaggedError — для ошибок (extends Error)
class TodoNotFoundError extends Data.TaggedError("TodoNotFound")<{
readonly todoId: TodoId
}> {}
// Разница:
const data = new TodoNotFoundData({ todoId: TodoId.make("abc") })
const error = new TodoNotFoundError({ todoId: TodoId.make("abc") })
data instanceof Error // false
error instanceof Error // true
// TaggedError работает с Effect.fail
Effect.fail(error) // ✅ Типизированная ошибка
Сравнение с Schema.TaggedError
import { Schema } from "effect"
// Schema.TaggedError — ошибка с Schema для сериализации
class TodoNotFoundSchema extends Schema.TaggedError<TodoNotFoundSchema>()(
"TodoNotFound",
{
todoId: Schema.String,
}
) {}
// Data.TaggedError — ошибка без Schema
class TodoNotFoundData extends Data.TaggedError("TodoNotFound")<{
readonly todoId: string
}> {}
// Schema.TaggedError полезна когда ошибку нужно
// сериализовать/десериализовать (API, Event Store)
// Подробнее — в статье 04-error-schemas.md
Таблица сравнения
| Свойство | class Error | Data.TaggedClass | Data.TaggedError | Schema.TaggedError |
|---|---|---|---|---|
_tag дискриминатор | ❌ | ✅ | ✅ | ✅ |
extends Error | ✅ | ❌ | ✅ | ✅ |
| Структурное равенство | ❌ | ✅ | ✅ | ✅ |
| Hash для коллекций | ❌ | ✅ | ✅ | ✅ |
| Иммутабельность | ❌ | ✅ | ✅ | ✅ |
| Schema (encode/decode) | ❌ | ❌ | ❌ | ✅ |
Работает с catchTag | ❌ | ❌ | ✅ | ✅ |
| Подходит для доменных ошибок | ❌ | ⚠️ | ✅ | ✅ |
Data.TaggedError и Cause
В Effect ошибки оборачиваются в Cause — структуру, которая хранит полную историю ошибки, включая вложенные причины и параллельные ошибки.
Cause.Fail — ожидаемая ошибка
import { Effect, Cause, Exit } from "effect"
const program = Effect.fail(new TodoNotFound({ todoId: TodoId.make("abc") }))
const exit = await Effect.runPromiseExit(program)
if (Exit.isFailure(exit)) {
// Cause содержит ошибку
const cause: Cause.Cause<TodoNotFound> = exit.cause
// Извлечение ошибки
const failure = Cause.failureOption(cause)
// Option.Some(TodoNotFound({ todoId: "abc" }))
}
Cause.Die — дефект (баг)
// Дефекты НЕ отражаются в типе E
const program: Effect.Effect<never, never> = Effect.die(
new Error("BUG: invariant violated")
)
const exit = await Effect.runPromiseExit(program)
if (Exit.isFailure(exit)) {
const defect = Cause.dieOption(exit.cause)
// Option.Some(Error("BUG: invariant violated"))
}
Cause.Parallel и Cause.Sequential
import { Effect } from "effect"
// Параллельное выполнение — ошибки собираются в Cause.Parallel
const parallel = Effect.all([
Effect.fail(new TodoNotFound({ todoId: TodoId.make("1") })),
Effect.fail(new TodoNotFound({ todoId: TodoId.make("2") })),
], { concurrency: "unbounded" })
// Последовательное — в Cause.Sequential
const sequential = pipe(
Effect.fail(new TodoNotFound({ todoId: TodoId.make("1") })),
Effect.zipRight(
Effect.fail(new TodoNotFound({ todoId: TodoId.make("2") }))
),
)
Паттерны создания ошибок
Паттерн 1: Smart Constructor для ошибки
Иногда полезно добавить статический метод-конструктор:
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
readonly todoId: TodoId
readonly currentStatus: TodoStatus
readonly targetStatus: TodoStatus
readonly allowedTransitions: ReadonlyArray<TodoStatus>
}> {
// Smart constructor: вычисляет allowedTransitions
static fromTodo(
todo: Todo,
targetStatus: TodoStatus,
transitionMap: ReadonlyMap<TodoStatus, ReadonlyArray<TodoStatus>>
): InvalidStatusTransition {
return new InvalidStatusTransition({
todoId: todo.id,
currentStatus: todo.status,
targetStatus,
allowedTransitions: transitionMap.get(todo.status) ?? [],
})
}
}
// Использование
Effect.fail(
InvalidStatusTransition.fromTodo(todo, "Completed", statusTransitions)
)
Паттерн 2: Группировка ошибок в namespace
// domain/errors.ts — единый файл экспорта всех доменных ошибок
import { Data } from "effect"
// --- Todo Errors ---
export class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly todoId: TodoId
}> {}
export class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
readonly todoId: TodoId
readonly currentStatus: TodoStatus
readonly targetStatus: TodoStatus
readonly allowedTransitions: ReadonlyArray<TodoStatus>
}> {}
export class DuplicateTodoTitle extends Data.TaggedError("DuplicateTodoTitle")<{
readonly title: string
readonly existingTodoId: Option.Option<TodoId>
}> {}
export class TodoListFull extends Data.TaggedError("TodoListFull")<{
readonly listId: TodoListId
readonly maxSize: number
readonly currentSize: number
}> {}
// --- Тип-объединение для удобства ---
export type TodoError =
| TodoNotFound
| InvalidStatusTransition
| DuplicateTodoTitle
| TodoListFull
Паттерн 3: Ошибка с вложенной причиной
class TodoCreationFailed extends Data.TaggedError("TodoCreationFailed")<{
readonly title: string
readonly reason: TodoNotFound | DuplicateTodoTitle | InvalidTodoTitle
}> {
get message(): string {
return `Failed to create todo "${this.title}": ${this.reason.message}`
}
}
// Оборачиваем цепочку ошибок
const createTodo = (title: string) =>
pipe(
validateTitle(title),
Effect.flatMap(checkDuplicate),
Effect.mapError(reason => new TodoCreationFailed({ title, reason })),
)
Типичные ошибки при использовании Data.TaggedError
Ошибка 1: Дублирование тега
// ❌ Два класса с одинаковым тегом — конфликт!
class TodoNotFound extends Data.TaggedError("NotFound")<{
readonly todoId: TodoId
}> {}
class UserNotFound extends Data.TaggedError("NotFound")<{
readonly userId: UserId
}> {}
// catchTag("NotFound") — какой из двух? Неоднозначность!
Решение: Всегда используйте уникальные, доменно-специфичные теги:
// ✅ Уникальные теги
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{...}> {}
class UserNotFound extends Data.TaggedError("UserNotFound")<{...}> {}
Ошибка 2: Мутабельные поля
// ❌ Нет readonly — поля мутабельны
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
todoId: string // мутабельное поле!
}> {}
// ✅ Всегда readonly
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly todoId: string
}> {}
Ошибка 3: Слишком широкие типы полей
// ❌ any и unknown скрывают информацию
class DomainError extends Data.TaggedError("DomainError")<{
readonly data: any
}> {}
// ✅ Точные доменные типы
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
readonly currentStatus: TodoStatus
readonly targetStatus: TodoStatus
}> {}
Ошибка 4: Логика в конструкторе
// ❌ Побочные эффекты в ошибке
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly todoId: TodoId
}> {
constructor(props: { todoId: TodoId }) {
super(props)
// ❌ Побочный эффект!
analytics.track("todo_not_found", { todoId: props.todoId })
}
}
// ✅ Ошибка — чистое значение, логирование — отдельно
const findTodo = (id: TodoId) =>
pipe(
repository.findById(id),
Effect.tapError(error =>
Effect.logWarning("Todo not found", { todoId: error.todoId })
),
)
Резюме
| Аспект | Описание |
|---|---|
Data.TaggedError(tag) | Фабрика для создания типизированных ошибок с дискриминатором |
_tag | Строковый дискриминатор для pattern matching и catchTag |
| Структурное равенство | Equal.equals сравнивает по значению, а не по ссылке |
| Иммутабельность | Все поля readonly, ошибка — Value Object |
extends Error | Совместимость с экосистемой JavaScript |
| Tagged Union | Объединение ошибок с exhaustiveness checking |
catchTag / catchTags | Типобезопасная обработка конкретных ошибок |
| Контекст | Ошибка содержит все данные для понимания ситуации |
| Уникальность тега | Каждый тег уникален в пределах домена |
В следующей статье мы рассмотрим иерархию ошибок — как организовать ошибки по слоям (Domain → Application → Infrastructure) и как ошибки трансформируются при пересечении границ.