Типобезопасный домен: Гексагональная архитектура на базе Effect Data.TaggedError: доменные ошибки как tagged unions
Глава

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 — специализированный конструктор ошибок, который обеспечивает:

  1. Тег _tag — уникальный дискриминатор для pattern matching
  2. Структурное равенство — сравнение по значению, а не по ссылке
  3. Иммутабельность — все поля readonly
  4. Совместимость с Error — наследует от Error, работает с cause и stack
  5. Интеграцию с 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 ErrorData.TaggedClassData.TaggedErrorSchema.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) и как ошибки трансформируются при пересечении границ.