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 при создании (валидация). Но эта валидация происходит на границе (при получении данных из внешнего мира), а не при каждой операции с значением.
Итоги главы
- Branded Types устраняют Primitive Obsession:
TodoId ≠ UserId, даже если оба —string - Schema.brand = валидация + метка: не просто метка, а полноценная проверка при создании
- Compile-time safety, zero runtime cost: Brand существует только в типах, не в runtime
- На каждой границе — decode: данные из внешнего мира проходят через Schema.decode, получая Brand
- Модуль идентификаторов: выносите все ID в один файл с общим шаблоном
makeId - Используйте Brand щедро: для ID, Email, URL, Title — для всего, что имеет доменное значение
Branded Types — это фундамент типобезопасности в доменной модели. В сочетании со Schema они создают непробиваемый контракт на уровне типов.
В следующей главе мы рассмотрим композицию схем — мощные операции для построения сложных структур из простых блоков.