Реализация Value Objects через Schema.Class и Brand
Все инструменты Effect для создания VO: Schema.brand для branded примитивов, Schema.Class для составных VO, Data.Class для легковесных VO, Data.TaggedEnum для перечислений, Schema.Literal для фиксированных множеств. Паттерн Smart Constructor. Разница между new и decode. Рецепты для Simple, Composite и Enumeration VO.
Введение
В предыдущей статье мы разобрали теорию Value Objects. Теперь пришло время практики — реализации Value Objects в Effect-ts. Библиотека Effect предоставляет несколько мощных инструментов для создания Value Objects, и выбор между ними зависит от конкретной задачи.
В этой статье мы подробно разберём все способы создания Value Objects в Effect-ts:
Schema.brand— branded примитивы (Simple VO)Schema.Class— составные VO с равенством и декодированиемData.Class— легковесные VO без Schema-валидацииData.TaggedEnum— VO-перечисленияSchema.Struct+Schema.brand— составные branded VO
Инструменты Effect для Value Objects
Обзор инструментов
| Инструмент | Назначение | Равенство | Валидация | Сериализация | Когда использовать |
|---|---|---|---|---|---|
Schema.brand | Branded примитив | Через === | Да (Schema) | Да | Простые VO: Email, TodoId |
Schema.Class | Полноценный класс | Structural (Equal) | Да (Schema) | Да | Составные VO: Money, Address |
Data.Class | Легковесный класс | Structural (Equal) | Нет | Нет | Внутренние VO без decode |
Data.TaggedEnum | Тегированное перечисление | Structural (Equal) | Нет | Нет | Статусы, приоритеты |
Schema.Literal | Литеральный тип | === | Да | Да | Фиксированные множества |
1. Schema.brand — Branded Примитивы
Что такое Brand
Brand — это механизм TypeScript, позволяющий создать номинальный тип из структурного. По умолчанию TypeScript использует структурную типизацию: два типа совместимы, если их структуры совпадают. Brand ломает эту совместимость, добавляя «невидимое» свойство к типу.
import { Schema, Brand } from "effect"
// Без Brand — это просто string
type Email1 = string
type PhoneNumber1 = string
declare const email1: Email1
declare const phone1: PhoneNumber1
const x: Email1 = phone1 // ✅ TypeScript разрешит — оба string
// С Brand — это разные типы!
type Email2 = string & Brand.Brand<"Email">
type PhoneNumber2 = string & Brand.Brand<"PhoneNumber">
declare const email2: Email2
declare const phone2: PhoneNumber2
// const y: Email2 = phone2 // ❌ Type error!
Создание Branded Schema
Schema.brand добавляет brand к существующей Schema, превращая примитивный тип в Value Object:
import { Schema } from "effect"
// ===========================
// Паттерн: Branded String VO
// ===========================
// TodoId — уникальный идентификатор задачи
const TodoId = Schema.String.pipe(
Schema.pattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i),
Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type
// TodoId = string & Brand<"TodoId">
// TodoTitle — заголовок задачи
const TodoTitle = Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
// TodoTitle = string & Brand<"TodoTitle">
// ===========================
// Паттерн: Branded Number VO
// ===========================
// Quantity — количество элементов
const Quantity = Schema.Number.pipe(
Schema.int(),
Schema.positive(),
Schema.brand("Quantity")
)
type Quantity = typeof Quantity.Type
// Quantity = number & Brand<"Quantity">
// Percentage — процент (0–100)
const Percentage = Schema.Number.pipe(
Schema.greaterThanOrEqualTo(0),
Schema.lessThanOrEqualTo(100),
Schema.brand("Percentage")
)
type Percentage = typeof Percentage.Type
Декодирование и кодирование
Branded Schema поддерживает полный цикл decode/encode:
import { Schema, Effect, Either } from "effect"
const TodoTitle = Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
// === Синхронное декодирование ===
// decodeUnknownSync — бросает исключение при ошибке
const title1 = Schema.decodeUnknownSync(TodoTitle)("Buy groceries")
// title1: TodoTitle
// decodeUnknownEither — возвращает Either
const result = Schema.decodeUnknownEither(TodoTitle)("")
// result: Either<TodoTitle, ParseError>
if (Either.isLeft(result)) {
console.log("Validation failed:", result.left)
} else {
console.log("Valid title:", result.right)
}
// === Асинхронное декодирование через Effect ===
// decodeUnknown — возвращает Effect
const titleEffect: Effect.Effect<TodoTitle, Schema.ParseError> =
Schema.decodeUnknown(TodoTitle)("Buy groceries")
// === Кодирование (encode) ===
// Brand удаляется при encode — получаем обычный string
const encoded = Schema.encodeSync(TodoTitle)(title1)
// encoded: string (без Brand)
Комбинирование Brands
Можно создавать Value Objects с несколькими brands для дополнительной типобезопасности:
import { Schema } from "effect"
// UUID v4 brand
const UUID = Schema.String.pipe(
Schema.pattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i),
Schema.brand("UUID")
)
// Конкретные идентификаторы — разные brands
const TodoId = UUID.pipe(Schema.brand("TodoId"))
const UserId = UUID.pipe(Schema.brand("UserId"))
const ProjectId = UUID.pipe(Schema.brand("ProjectId"))
type TodoId = typeof TodoId.Type
// TodoId = string & Brand<"UUID"> & Brand<"TodoId">
type UserId = typeof UserId.Type
// UserId = string & Brand<"UUID"> & Brand<"UserId">
// Нельзя перепутать!
declare const todoId: TodoId
declare const userId: UserId
// const wrong: TodoId = userId // ❌ Type error!
Паттерн: VO с кастомным сообщением об ошибке
import { Schema } from "effect"
const TodoTitle = Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1, {
message: () => "Заголовок задачи не может быть пустым"
}),
Schema.maxLength(200, {
message: () => "Заголовок задачи не может превышать 200 символов"
}),
Schema.brand("TodoTitle"),
Schema.annotations({
identifier: "TodoTitle",
title: "Todo Title",
description: "Заголовок задачи: непустая строка от 1 до 200 символов"
})
)
2. Schema.Class — Составные Value Objects
Когда использовать Schema.Class
Schema.Class идеально подходит для составных Value Objects, которые:
- Состоят из нескольких полей.
- Требуют валидации при декодировании.
- Нуждаются в сериализации/десериализации (границы слоёв).
- Должны поддерживать structural equality.
Базовое создание
import { Schema } from "effect"
// Money — классический составной Value Object
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.nonNegative()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {}
// Создание экземпляра (без валидации — прямой конструктор)
const price = new Money({ amount: 100, currency: "USD" })
price.amount // 100
price.currency // "USD"
// Создание с валидацией (через decode)
const priceEffect = Schema.decodeUnknown(Money)({
amount: 100,
currency: "USD"
})
// Effect<Money, ParseError>
Важно: new vs decode
Критически важное различие — new и Schema.decode работают по-разному:
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.nonNegative()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {}
// new — НЕ выполняет валидацию Schema filters!
// TypeScript проверяет типы, но runtime-ограничения не проверяются
const dangerous = new Money({ amount: -100, currency: "USD" })
// ⚠️ Создастся без ошибки, хотя amount отрицательный!
// TypeScript видит number, а не "nonNegative number"
// Schema.decode — ВЫПОЛНЯЕТ все проверки
const safe = Schema.decodeUnknownSync(Money)({ amount: -100, currency: "USD" })
// ❌ ParseError: Expected a non-negative number, actual -100
// РЕКОМЕНДАЦИЯ: используйте smart constructor
const createMoney = Schema.decodeUnknown(Money)
// Теперь вызов гарантирует валидацию
const money = createMoney({ amount: 100, currency: "USD" })
// Effect<Money, ParseError>
Паттерн: Smart Constructor для Schema.Class
import { Schema, Effect, pipe } from "effect"
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.nonNegative()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {
// Smart constructor как статический метод
static readonly create = Schema.decodeUnknown(Money)
// Удобный синхронный конструктор (бросает при ошибке)
static readonly unsafeCreate = Schema.decodeUnknownSync(Money)
}
// Использование
const money1 = Money.create({ amount: 100, currency: "USD" })
// Effect<Money, ParseError>
const money2 = Money.unsafeCreate({ amount: 100, currency: "USD" })
// Money (или бросит исключение)
Добавление поведения (методов)
Schema.Class поддерживает добавление методов — бизнес-логики Value Object:
import { Schema, Equal, Data } from "effect"
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.nonNegative()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {
// Сложение денег одной валюты
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(
`Cannot add ${this.currency} and ${other.currency}: currency mismatch`
)
}
return new Money({
amount: this.amount + other.amount,
currency: this.currency
})
}
// Умножение на коэффициент
multiply(factor: number): Money {
if (factor < 0) {
throw new Error("Factor must be non-negative")
}
return new Money({
amount: this.amount * factor,
currency: this.currency
})
}
// Проверка: больше нуля
isPositive(): boolean {
return this.amount > 0
}
// Проверка: нулевая сумма
isZero(): boolean {
return this.amount === 0
}
// Форматирование для отображения
format(): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: this.currency
}).format(this.amount)
}
}
// Использование
const price = new Money({ amount: 100, currency: "USD" })
const tax = new Money({ amount: 8.5, currency: "USD" })
const total = price.add(tax)
console.log(total.format()) // "$108.50"
Однако: предпочитайте чистые функции
В функциональном стиле лучше выносить операции в отдельные функции вместо методов класса. Это делает логику более композируемой и тестируемой:
import { Schema, Effect, Either, pipe } from "effect"
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.nonNegative()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {}
// === Чистые функции для операций над Money ===
// Тегированная ошибка
class CurrencyMismatch extends Schema.TaggedError<CurrencyMismatch>()(
"CurrencyMismatch",
{
left: Schema.String,
right: Schema.String,
message: Schema.String
}
) {}
const addMoney = (a: Money, b: Money): Either.Either<Money, CurrencyMismatch> =>
a.currency !== b.currency
? Either.left(new CurrencyMismatch({
left: a.currency,
right: b.currency,
message: `Cannot add ${a.currency} and ${b.currency}`
}))
: Either.right(new Money({
amount: a.amount + b.amount,
currency: a.currency
}))
const multiplyMoney = (money: Money, factor: number): Money =>
new Money({
amount: money.amount * Math.abs(factor),
currency: money.currency
})
const formatMoney = (money: Money): string =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: money.currency
}).format(money.amount)
const isZero = (money: Money): boolean => money.amount === 0
// Использование через pipe
const result = pipe(
addMoney(
new Money({ amount: 100, currency: "USD" }),
new Money({ amount: 8.5, currency: "USD" })
),
Either.map(formatMoney)
)
// Either<string, CurrencyMismatch>
3. Data.Class — Легковесные Value Objects
Когда использовать Data.Class
Data.Class подходит, когда нужен Value Object с structural equality, но без Schema-валидации и сериализации. Это полезно для:
- Внутренних доменных VO, которые не пересекают границы слоёв.
- VO, которые создаются только из уже валидных данных.
- Случаев, когда нужна максимальная производительность.
import { Data, Equal } from "effect"
// Простой VO через Data.Class
class Point extends Data.Class<{
readonly x: number
readonly y: number
}> {}
const p1 = new Point({ x: 1, y: 2 })
const p2 = new Point({ x: 1, y: 2 })
const p3 = new Point({ x: 3, y: 4 })
// Structural equality из коробки
console.log(Equal.equals(p1, p2)) // true
console.log(Equal.equals(p1, p3)) // false
// Иммутабельность
// p1.x = 5 // ❌ Error: readonly property
Data.Class vs Schema.Class
import { Data, Schema, Equal } from "effect"
// Data.Class — без Schema
class DataPoint extends Data.Class<{
readonly x: number
readonly y: number
}> {}
// ✅ Structural equality
// ✅ Иммутабельность (readonly)
// ❌ Нет валидации при создании
// ❌ Нет encode/decode
// ❌ Нет Schema интеграции
// Schema.Class — с Schema
class SchemaPoint extends Schema.Class<SchemaPoint>("SchemaPoint")({
x: Schema.Number,
y: Schema.Number
}) {}
// ✅ Structural equality
// ✅ Иммутабельность (readonly)
// ✅ Валидация через Schema.decode
// ✅ Encode/decode для сериализации
// ✅ Полная Schema интеграция
Data.struct и Data.tuple — одноразовые VO
Для быстрого создания Value Objects без объявления класса:
import { Data, Equal } from "effect"
// Data.struct — создание VO из объекта
const point1 = Data.struct({ x: 1, y: 2 })
const point2 = Data.struct({ x: 1, y: 2 })
console.log(Equal.equals(point1, point2)) // true
// Data.tuple — создание VO из кортежа
const range1 = Data.tuple(1, 10)
const range2 = Data.tuple(1, 10)
console.log(Equal.equals(range1, range2)) // true
4. Data.TaggedEnum — VO-Перечисления
Когда использовать TaggedEnum
Data.TaggedEnum идеально подходит для Value Objects с фиксированным набором вариантов, где каждый вариант может нести дополнительные данные:
import { Data, Match } from "effect"
// Статус задачи — тегированное перечисление
type TodoStatus = Data.TaggedEnum<{
readonly Draft: {}
readonly Active: {}
readonly Completed: { readonly completedAt: Date }
readonly Archived: { readonly archivedAt: Date; readonly reason: string }
}>
const { Draft, Active, Completed, Archived } = Data.taggedEnum<TodoStatus>()
// Создание
const status1 = Draft()
const status2 = Active()
const status3 = Completed({ completedAt: new Date() })
const status4 = Archived({
archivedAt: new Date(),
reason: "No longer relevant"
})
// Pattern matching с Match
const describeStatus = (status: TodoStatus): string =>
Match.value(status).pipe(
Match.tag("Draft", () => "Черновик"),
Match.tag("Active", () => "Активная"),
Match.tag("Completed", ({ completedAt }) =>
`Завершена ${completedAt.toLocaleDateString()}`
),
Match.tag("Archived", ({ reason }) => `Архивирована: ${reason}`),
Match.exhaustive
)
Сравнение с Schema.Literal
Для простых перечислений без дополнительных данных Schema.Literal проще:
import { Schema, Data, Match } from "effect"
// ✅ Простое перечисление — Schema.Literal
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type
// "low" | "medium" | "high" | "critical"
// ✅ Перечисление с данными — Data.TaggedEnum
type NotificationChannel = Data.TaggedEnum<{
readonly Email: { readonly address: string }
readonly SMS: { readonly phone: string }
readonly Push: { readonly deviceToken: string }
readonly Webhook: { readonly url: string; readonly secret: string }
}>
5. Schema.Struct + Brand — Составные Branded VO
Когда использовать
Иногда нужен составной Value Object с brand, но без полноценного класса. Schema.Struct + Schema.brand даёт это:
import { Schema } from "effect"
// Координаты — составной branded тип
const GeoCoordinates = Schema.Struct({
latitude: Schema.Number.pipe(
Schema.greaterThanOrEqualTo(-90),
Schema.lessThanOrEqualTo(90)
),
longitude: Schema.Number.pipe(
Schema.greaterThanOrEqualTo(-180),
Schema.lessThanOrEqualTo(180)
)
}).pipe(Schema.brand("GeoCoordinates"))
type GeoCoordinates = typeof GeoCoordinates.Type
// { latitude: number; longitude: number } & Brand<"GeoCoordinates">
// Декодирование
const coords = Schema.decodeUnknownSync(GeoCoordinates)({
latitude: 55.7558,
longitude: 37.6173
})
Полный паттерн создания Value Object
Рецепт для Simple VO (один примитив)
import { Schema } from "effect"
// Шаг 1: Определяем Schema с ограничениями
// Шаг 2: Добавляем brand
// Шаг 3: Экспортируем тип
// Шаг 4: Создаём smart constructors
// === TodoId ===
export const TodoId = Schema.String.pipe(
Schema.pattern(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
),
Schema.brand("TodoId"),
Schema.annotations({
identifier: "TodoId",
description: "UUID v4 identifier for Todo entity"
})
)
export type TodoId = typeof TodoId.Type
// Smart constructors
export const decodeTodoId = Schema.decodeUnknown(TodoId)
export const decodeTodoIdSync = Schema.decodeUnknownSync(TodoId)
// Фабрика для генерации новых TodoId
export const generateTodoId = (): TodoId =>
crypto.randomUUID() as TodoId
Рецепт для Composite VO (несколько полей)
import { Schema, Equal, Data } from "effect"
// Шаг 1: Определяем Schema.Class с полями
// Шаг 2: Добавляем операции как чистые функции
// Шаг 3: Экспортируем тип и smart constructor
// Шаг 4: (Опционально) Добавляем методы
// === DateRange ===
export class DateRange extends Schema.Class<DateRange>("DateRange")({
start: Schema.DateFromString,
end: Schema.DateFromString
}) {}
// Schema-level валидация (инварианты)
export const ValidDateRange = DateRange.pipe(
Schema.filter(
(range) =>
range.start <= range.end
? undefined
: "Start must be before or equal to end"
)
)
// Чистые функции
export const dateRangeDurationDays = (range: DateRange): number =>
Math.ceil(
(range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)
)
export const dateRangeContains = (range: DateRange, date: Date): boolean =>
date >= range.start && date <= range.end
export const dateRangeOverlaps = (a: DateRange, b: DateRange): boolean =>
a.start <= b.end && b.start <= a.end
// Smart constructors
export const createDateRange = Schema.decodeUnknown(ValidDateRange)
Рецепт для Enumeration VO
import { Schema } from "effect"
// Вариант A: Schema.Literal (простое перечисление)
export const Priority = Schema.Literal("low", "medium", "high", "critical")
export type Priority = typeof Priority.Type
// Чистые функции
export const priorityWeight = (p: Priority): number => {
const weights: Record<Priority, number> = {
low: 1,
medium: 2,
high: 3,
critical: 4
} as const
return weights[p]
}
export const comparePriority = (a: Priority, b: Priority): number =>
priorityWeight(a) - priorityWeight(b)
export const isUrgent = (p: Priority): boolean =>
p === "high" || p === "critical"
Интеграция с Effect.Service (связь с Hexagonal)
Value Objects используются в портах как типы контрактов:
import { Schema, Effect, Context } from "effect"
// === Value Objects ===
export const TodoId = Schema.String.pipe(
Schema.pattern(/^[0-9a-f-]+$/),
Schema.brand("TodoId")
)
export type TodoId = typeof TodoId.Type
export class TodoTitle extends Schema.Class<TodoTitle>("TodoTitle")({
value: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200))
}) {}
export const Priority = Schema.Literal("low", "medium", "high", "critical")
export type Priority = typeof Priority.Type
// === Entity (использует Value Objects) ===
export class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200)),
priority: Priority,
completed: Schema.Boolean,
createdAt: Schema.DateFromSelf
}) {}
// === Port (контракт с Value Objects) ===
export class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>>
readonly save: (todo: Todo) => Effect.Effect<void>
readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
}
>() {}
// Value Objects гарантируют, что:
// 1. В findById нельзя передать UserId вместо TodoId (branded types)
// 2. В findByPriority нельзя передать "invalid" (literal types)
// 3. В save нельзя передать невалидный Todo (Schema.Class)
Типичные ошибки и как их избежать
Ошибка 1: Использование new вместо decode для Schema.Class
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.positive()),
currency: Schema.Literal("USD", "EUR")
}) {}
// ❌ ОПАСНО: new не выполняет Schema-валидацию
const bad = new Money({ amount: -100, currency: "USD" })
// amount = -100 проходит без ошибки!
// ✅ БЕЗОПАСНО: decode выполняет все проверки
const good = Schema.decodeUnknownSync(Money)({ amount: 100, currency: "USD" })
Правило: Используйте new только когда вы абсолютно уверены в валидности данных (например, внутри доменных операций, где данные уже прошли валидацию). На границах слоёв — всегда decode.
Ошибка 2: Забыть экспортировать тип
// ❌ Забыли экспортировать тип
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
// Тип доступен только через typeof TodoId.Type
// ✅ Экспортируем и Schema, и тип
export const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
export type TodoId = typeof TodoId.Type
Ошибка 3: Избыточный Brand на Schema.Class
// ❌ Schema.Class уже создаёт уникальный тип — Brand не нужен
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number,
currency: Schema.String
}) {}
// Money — уже уникальный номинальный тип благодаря Schema.Class
// ✅ Brand нужен для примитивов, а не для классов
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
Ошибка 4: Schema.Class без readonly
// В Schema.Class все поля автоматически readonly
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number, // Уже readonly!
currency: Schema.String // Уже readonly!
}) {}
const m = new Money({ amount: 100, currency: "USD" })
// m.amount = 200 // ❌ Error: readonly
Резюме
| Подход | Подходит для | Валидация | Equality | Сериализация |
|---|---|---|---|---|
Schema.brand | Simple VO (Email, TodoId) | ✅ | === на примитиве | ✅ |
Schema.Class | Composite VO (Money, Address) | ✅ | ✅ Structural | ✅ |
Data.Class | Внутренние VO без decode | ❌ | ✅ Structural | ❌ |
Data.TaggedEnum | Перечисления с данными | ❌ | ✅ Structural | ❌ |
Schema.Literal | Простые перечисления | ✅ | === | ✅ |
Главное правило: на границах слоёв (Ports, HTTP, Database) используйте Schema.Class и Schema.brand — они обеспечивают валидацию и сериализацию. Внутри домена допустимо использовать Data.Class для легковесных VO.
В следующих статьях мы подробно разберём конкретные примеры: Email, Money и другие Value Objects с полной реализацией и тестами.