Типобезопасный домен: Гексагональная архитектура на базе Effect Branded Types: TodoId, UserId — типобезопасные идентификаторы
Глава

Branded Types: TodoId, UserId — типобезопасные идентификаторы

Проблема примитивной одержимости. Brand как номинальная типизация. Schema.brand — рефайнмент + метка. Полный набор идентификаторов домена. Brand для Value Objects. UUID как Branded Type. Множественные Brand. Brand.nominal. Конверсия между типами. Модуль идентификаторов. Типобезопасность в Map/Set и Domain Events. Нулевой runtime overhead.

Введение: проблема примитивной одержимости

“Примитивная одержимость” (Primitive Obsession) — один из самых распространённых антипаттернов в разработке. Суть проста: вместо создания доменных типов разработчики используют примитивы (string, number) для представления доменных понятий.

// ❌ Primitive Obsession
function assignTodo(todoId: string, userId: string): void {
  // ...
}

// Катастрофа: перепутали порядок аргументов
assignTodo(userId, todoId) // ← компилируется без ошибок!
// userId передан как todoId, todoId передан как userId

TypeScript не различает string для TodoId и string для UserId — оба являются просто string. Это приводит к ошибкам, которые невозможно отловить на этапе компиляции.

Branded Types решают эту проблему, создавая номинальные типы поверх примитивов. TodoId и UserId становятся различными типами, несмотря на то что оба базируются на string.


Что такое Brand

Brand в Effect — это механизм номинальной типизации для TypeScript. Он добавляет “невидимую метку” к типу:

import { Brand } from "effect"

// Brand создаёт фантомный тип
type TodoId = string & Brand.Brand<"TodoId">
type UserId = string & Brand.Brand<"UserId">

// Runtime-значение — обычная строка
// Compile-time — это разные типы!

Под капотом Brand.Brand<"TodoId"> добавляет символьное свойство, которое существует только на уровне типов:

// Упрощённо, Brand.Brand<K> выглядит так:
interface Brand<K extends string> {
  readonly [BrandSymbol]: { readonly [k in K]: K }
}

// Поэтому:
type TodoId = string & { readonly [BrandSymbol]: { readonly TodoId: "TodoId" } }
type UserId = string & { readonly [BrandSymbol]: { readonly UserId: "UserId" } }
// Это РАЗНЫЕ типы, несмотря на одинаковую runtime-природу

Schema.brand — рефайнмент + Brand

Effect Schema предоставляет Schema.brand — самый удобный способ создания branded types:

import { Schema } from "effect"

// Создание branded schema
const TodoId = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.brand("TodoId")
)

// Извлечение типа
type TodoId = typeof TodoId.Type
// string & Brand<"TodoId">

// Encoded тип — обычная строка
type TodoIdEncoded = typeof TodoId.Encoded
// string

// Decode — превращает string в TodoId
const todoId = Schema.decodeUnknownSync(TodoId)("abc-123")
// todoId: TodoId (string & Brand<"TodoId">)

// ❌ Обычную строку нельзя использовать как TodoId
const rawString: string = "abc-123"
// const id: TodoId = rawString // Type error!

// ✅ Только через decode
const validId: TodoId = Schema.decodeUnknownSync(TodoId)(rawString)

Валидация при создании

Brand через Schema не просто навешивает метку — он валидирует данные:

import { Schema } from "effect"

const TodoId = Schema.String.pipe(
  Schema.nonEmptyString(),      // Не может быть пустым
  Schema.pattern(/^todo_/),     // Должен начинаться с "todo_"
  Schema.maxLength(50),         // Не длиннее 50 символов
  Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type

// ✅ Валидный
Schema.decodeUnknownSync(TodoId)("todo_abc123")
// TodoId

// ❌ Невалидные
Schema.decodeUnknownSync(TodoId)("")              // ParseError: пустая строка
Schema.decodeUnknownSync(TodoId)("usr_abc")       // ParseError: не начинается с "todo_"
Schema.decodeUnknownSync(TodoId)("todo_" + "x".repeat(50)) // ParseError: слишком длинный

Полный набор идентификаторов для Todo-домена

import { Schema } from "effect"

// ═══════════════════════════════════════
// Идентификаторы — branded types
// ═══════════════════════════════════════

/** Уникальный идентификатор задачи */
const TodoId = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.brand("TodoId"),
  Schema.annotations({
    title: "TodoId",
    description: "Уникальный идентификатор задачи",
    examples: ["todo_01HGE3APZF1P2C5BNJ7MQK63JR"]
  })
)
type TodoId = typeof TodoId.Type

/** Уникальный идентификатор пользователя */
const UserId = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.brand("UserId"),
  Schema.annotations({
    title: "UserId",
    description: "Уникальный идентификатор пользователя",
    examples: ["usr_01HGE3APZF1P2C5BNJ7MQK63JR"]
  })
)
type UserId = typeof UserId.Type

/** Идентификатор списка задач */
const TodoListId = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.brand("TodoListId"),
  Schema.annotations({
    title: "TodoListId",
    description: "Уникальный идентификатор списка задач"
  })
)
type TodoListId = typeof TodoListId.Type

/** Идентификатор тега */
const TagId = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.brand("TagId")
)
type TagId = typeof TagId.Type

// ═══════════════════════════════════════
// Теперь ошибки пойманы на компиляции!
// ═══════════════════════════════════════

function assignTodo(todoId: TodoId, userId: UserId): void {
  console.log(`Assigning ${todoId} to ${userId}`)
}

const todoId = Schema.decodeUnknownSync(TodoId)("todo_123")
const userId = Schema.decodeUnknownSync(UserId)("usr_456")

assignTodo(todoId, userId) // ✅ Правильный порядок

// assignTodo(userId, todoId)
// ❌ Compile error!
// Argument of type 'UserId' is not assignable to parameter of type 'TodoId'

Brand для Value Objects

Brand не ограничивается идентификаторами. Любое доменное значение может быть branded:

import { Schema } from "effect"

// ═══════════════════════════════════════
// Строковые Value Objects
// ═══════════════════════════════════════

/** Email — branded string с валидацией формата */
const Email = Schema.Trim.pipe(
  Schema.compose(Schema.Lowercase),
  Schema.nonEmptyString(),
  Schema.maxLength(254),
  Schema.pattern(/^[^@\s]+@[^@\s]+\.[^@\s]+$/),
  Schema.brand("Email"),
  Schema.annotations({ title: "Email" })
)
type Email = typeof Email.Type
// string & Brand<"Email">

/** URL — branded string */
const Url = Schema.String.pipe(
  Schema.pattern(/^https?:\/\/.+/),
  Schema.brand("Url"),
  Schema.annotations({ title: "Url" })
)
type Url = typeof Url.Type

/** Заголовок задачи */
const TodoTitle = Schema.Trim.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255),
  Schema.brand("TodoTitle"),
  Schema.annotations({ title: "TodoTitle" })
)
type TodoTitle = typeof TodoTitle.Type

// ═══════════════════════════════════════
// Числовые Value Objects
// ═══════════════════════════════════════

/** Порядковый номер (для сортировки) */
const SortOrder = Schema.Number.pipe(
  Schema.int(),
  Schema.nonNegative(),
  Schema.brand("SortOrder")
)
type SortOrder = typeof SortOrder.Type
// number & Brand<"SortOrder">

/** Процент выполнения */
const Percentage = Schema.Number.pipe(
  Schema.finite(),
  Schema.greaterThanOrEqualTo(0),
  Schema.lessThanOrEqualTo(100),
  Schema.brand("Percentage")
)
type Percentage = typeof Percentage.Type

/** Версия агрегата (для optimistic locking) */
const AggregateVersion = Schema.Number.pipe(
  Schema.int(),
  Schema.nonNegative(),
  Schema.brand("AggregateVersion")
)
type AggregateVersion = typeof AggregateVersion.Type

UUID как Branded Type

Очень частый паттерн — использование UUID в качестве идентификаторов:

import { Schema } from "effect"

// Базовый UUID
const UUID = Schema.UUID.pipe(
  Schema.brand("UUID")
)

// Специализированные UUID для разных сущностей
const TodoId = Schema.UUID.pipe(
  Schema.brand("TodoId"),
  Schema.annotations({
    title: "TodoId",
    description: "UUID v4 идентификатор задачи"
  })
)
type TodoId = typeof TodoId.Type
// string & Brand<"TodoId">

const UserId = Schema.UUID.pipe(
  Schema.brand("UserId"),
  Schema.annotations({ title: "UserId" })
)
type UserId = typeof UserId.Type
// string & Brand<"UserId">

// Генерация нового TodoId
import { Effect } from "effect"

const generateTodoId = Effect.sync(() =>
  Schema.decodeUnknownSync(TodoId)(crypto.randomUUID())
)
// Effect<TodoId, never, never>

Множественные Brand (Multi-brand)

Иногда значение имеет несколько характеристик одновременно:

import { Schema } from "effect"

// Значение может быть branded несколькими метками
const PositiveEvenInt = Schema.Number.pipe(
  Schema.int(),
  Schema.positive(),
  Schema.filter((n) => n % 2 === 0, {
    message: () => "Должно быть чётным"
  }),
  Schema.brand("Positive"),
  Schema.brand("Even")
)
type PositiveEvenInt = typeof PositiveEvenInt.Type
// number & Brand<"Positive"> & Brand<"Even">

// Функция, принимающая только положительные
function onlyPositive(n: number & Brand.Brand<"Positive">): void { /* ... */ }

// Функция, принимающая только чётные
function onlyEven(n: number & Brand.Brand<"Even">): void { /* ... */ }

const value = Schema.decodeUnknownSync(PositiveEvenInt)(4)
onlyPositive(value) // ✅ PositiveEvenInt совместим с Positive
onlyEven(value)     // ✅ PositiveEvenInt совместим с Even

Brand.nominal — альтернативный подход

Помимо Schema.brand, Effect предоставляет Brand.nominal для создания branded types без Schema:

import { Brand } from "effect"

// Brand.nominal — для случаев, когда Schema не нужен
type TodoId = string & Brand.Brand<"TodoId">
const TodoId = Brand.nominal<TodoId>()

// Создание
const id = TodoId("todo_123") // TodoId
// Не кидает ошибку — Brand.nominal просто навешивает метку

// Brand.refined — с валидацией
type Email = string & Brand.Brand<"Email">
const Email = Brand.refined<Email>(
  (s) => /^[^@]+@[^@]+\.[^@]+$/.test(s),
  (s) => Brand.error(`Invalid email: ${s}`)
)

const email = Email("user@example.com") // Email
// const bad = Email("not-email")        // throws BrandError

Рекомендация: в контексте доменного моделирования предпочитайте Schema.brand вместо Brand.nominal/refined. Schema даёт полный набор возможностей (decode/encode, arbitrary, annotations), а Brand.nominal — только метку.


Конверсия между Branded Types

Иногда нужно преобразовать один branded type в другой:

import { Schema, Effect } from "effect"

const TodoId = Schema.String.pipe(Schema.nonEmptyString(), Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type

const EntityId = Schema.String.pipe(Schema.nonEmptyString(), Schema.brand("EntityId"))
type EntityId = typeof EntityId.Type

// ═══════════════════════════════════════
// Безопасная конверсия через decode
// ═══════════════════════════════════════

/** Конвертировать TodoId в EntityId */
const todoIdToEntityId = (todoId: TodoId): Effect.Effect<EntityId, Schema.ParseError> =>
  Schema.decode(EntityId)(todoId as string) // явно расширяем до string

/** Конвертировать EntityId в TodoId */
const entityIdToTodoId = (entityId: EntityId): Effect.Effect<TodoId, Schema.ParseError> =>
  Schema.decode(TodoId)(entityId as string)

// ═══════════════════════════════════════
// Unsafe конверсия (когда уверены)
// ═══════════════════════════════════════

const unsafeTodoIdToEntityId = (todoId: TodoId): EntityId =>
  Schema.decodeUnknownSync(EntityId)(todoId as string)

Brand в контексте гексагональной архитектуры

Домен → Порт → Адаптер: Brand на каждой границе

import { Schema, Context, Effect } from "effect"

// ═══════════════════════════════════════
// ДОМЕН: определяет branded types
// ═══════════════════════════════════════

const TodoId = Schema.UUID.pipe(
  Schema.brand("TodoId"),
  Schema.annotations({ title: "TodoId" })
)
type TodoId = typeof TodoId.Type

const TodoTitle = Schema.Trim.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type

class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  completed: Schema.Boolean
}) {}

// ═══════════════════════════════════════
// ПОРТ: использует branded types
// ═══════════════════════════════════════

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    // Методы порта принимают и возвращают branded types
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
    readonly save: (todo: Todo) => Effect.Effect<void>
    readonly findByTitle: (title: TodoTitle) => Effect.Effect<ReadonlyArray<Todo>>
  }
>() {}

// ═══════════════════════════════════════
// АДАПТЕР: преобразует branded types ↔ инфраструктуру
// ═══════════════════════════════════════

// В адаптере: decode из инфраструктурного формата в доменный
const rowToTodo = (row: { id: string; title: string; completed: number }) =>
  Effect.gen(function* () {
    const id = yield* Schema.decode(TodoId)(row.id)
    const title = yield* Schema.decode(TodoTitle)(row.title)
    return new Todo({
      id,
      title,
      completed: row.completed === 1
    })
  })

// В адаптере: encode из доменного формата в инфраструктурный
const todoToRow = (todo: Todo) => ({
  id: todo.id as string,           // Brand -> string для SQL
  title: todo.title as string,     // Brand -> string для SQL
  completed: todo.completed ? 1 : 0
})

Driving Adapter: Brand на входе

import { Schema, Effect } from "effect"

// HTTP Handler — декодирует входные данные в branded types
const createTodoHandler = Effect.gen(function* () {
  // Из HTTP-запроса приходит сырой JSON
  const rawBody: unknown = yield* getRequestBody()

  // Schema.decode превращает string → TodoTitle (с валидацией)
  const input = yield* Schema.decodeUnknown(CreateTodoInput)(rawBody)
  // input.title: TodoTitle — уже валидный branded type!

  // Use case работает с branded types
  const todo = yield* createTodoUseCase(input)

  // Отправляем ответ
  return yield* sendJson(todo)
})

Паттерн: модуль идентификаторов

В реальном проекте все branded identifiers выносятся в отдельный модуль:

// domain/identifiers.ts

import { Schema, Effect } from "effect"

// ═══════════════════════════════════════
// Базовый шаблон для ID
// ═══════════════════════════════════════
const makeId = <B extends string>(brand: B) =>
  Schema.UUID.pipe(
    Schema.brand(brand),
    Schema.annotations({
      title: brand,
      description: `Уникальный идентификатор (${brand})`
    })
  )

// ═══════════════════════════════════════
// Все идентификаторы домена
// ═══════════════════════════════════════
export const TodoId = makeId("TodoId")
export type TodoId = typeof TodoId.Type

export const UserId = makeId("UserId")
export type UserId = typeof UserId.Type

export const TodoListId = makeId("TodoListId")
export type TodoListId = typeof TodoListId.Type

export const CommentId = makeId("CommentId")
export type CommentId = typeof CommentId.Type

export const AttachmentId = makeId("AttachmentId")
export type AttachmentId = typeof AttachmentId.Type

// ═══════════════════════════════════════
// Фабрика для генерации ID
// ═══════════════════════════════════════
export const generateId = <B extends string>(schema: Schema.Schema<string & Brand.Brand<B>, string>) =>
  Effect.sync(() => Schema.decodeUnknownSync(schema)(crypto.randomUUID()))

// Использование:
// const newTodoId = generateId(TodoId)
// const newUserId = generateId(UserId)

Типобезопасность Brand: реальные сценарии

Сценарий 1: Предотвращение путаницы ID

// Без Brand — компилируется, но содержит баг
function attachFileWrong(todoId: string, attachmentId: string, userId: string): void {
  // Что если вызвали: attachFile(attachmentId, todoId, userId)?
  // Никаких ошибок компиляции!
}

// С Brand — баг пойман на компиляции
function attachFile(todoId: TodoId, attachmentId: AttachmentId, userId: UserId): void {
  // attachFile(attachmentId, todoId, userId)
  // ❌ Type error!
  // Argument of type 'AttachmentId' is not assignable to parameter of type 'TodoId'
}

Сценарий 2: Типобезопасные Map/Set

import { Schema, HashMap, HashSet } from "effect"

// Map с branded keys
const todoById: HashMap.HashMap<TodoId, Todo> = HashMap.empty()

// Нельзя случайно использовать UserId как ключ
// HashMap.get(todoById, userId) // ❌ Type error!
// HashMap.get(todoById, todoId) // ✅ OK

// Set с branded values
const completedIds: HashSet.HashSet<TodoId> = HashSet.empty()
// HashSet.has(completedIds, userId) // ❌ Type error!
// HashSet.has(completedIds, todoId) // ✅ OK

Сценарий 3: Типобезопасные Domain Events

import { Schema } from "effect"

class TodoAssigned extends Schema.TaggedClass<TodoAssigned>()("TodoAssigned", {
  todoId: TodoId,       // ← не string, а TodoId
  assigneeId: UserId,   // ← не string, а UserId
  assignedBy: UserId,   // ← не string, а UserId
  occurredAt: Schema.DateFromString
}) {}

// При создании события невозможно перепутать поля:
new TodoAssigned({
  todoId: todoId,           // TodoId
  assigneeId: assigneeUserId, // UserId
  assignedBy: currentUserId,   // UserId
  occurredAt: new Date()
})

// new TodoAssigned({
//   todoId: assigneeUserId,  // ❌ Type error: UserId ≠ TodoId
//   assigneeId: todoId,       // ❌ Type error: TodoId ≠ UserId
//   ...
// })

Performance: Brand в runtime

Важный вопрос: добавляет ли Brand overhead в runtime?

Нет. Brand — это чисто compile-time механизм. В runtime branded value — это обычный примитив:

const todoId: TodoId = Schema.decodeUnknownSync(TodoId)("550e8400-e29b-41d4-a716-446655440000")

// В runtime:
console.log(typeof todoId) // "string"
console.log(todoId === "550e8400-e29b-41d4-a716-446655440000") // true

// Размер в памяти — такой же, как у обычной строки
// Нет wrapper-объектов, нет boxing

Единственная стоимость — decode при создании (валидация). Но эта валидация происходит на границе (при получении данных из внешнего мира), а не при каждой операции с значением.


Итоги главы

  1. Branded Types устраняют Primitive Obsession: TodoId ≠ UserId, даже если оба — string
  2. Schema.brand = валидация + метка: не просто метка, а полноценная проверка при создании
  3. Compile-time safety, zero runtime cost: Brand существует только в типах, не в runtime
  4. На каждой границе — decode: данные из внешнего мира проходят через Schema.decode, получая Brand
  5. Модуль идентификаторов: выносите все ID в один файл с общим шаблоном makeId
  6. Используйте Brand щедро: для ID, Email, URL, Title — для всего, что имеет доменное значение

Branded Types — это фундамент типобезопасности в доменной модели. В сочетании со Schema они создают непробиваемый контракт на уровне типов.

В следующей главе мы рассмотрим композицию схем — мощные операции для построения сложных структур из простых блоков.