Равенство Value Objects: Equal trait из Effect
Проблема ссылочного равенства в JavaScript. Equal trait — интерфейс структурного сравнения. Hash trait — спутник Equal, контракт equal→same hash. Автоматический Equal в Data.Class и Schema.Class. Глубокое сравнение вложенных VO. Branded types и ===. Паттерны сравнения: прямое, в условиях, поиск в массиве. Тестирование Equal-контракта (рефлексивность, симметричность, транзитивность).
Введение
Равенство — одна из определяющих характеристик Value Object. Два Value Object с одинаковыми атрибутами должны быть равны. Это звучит просто, но в JavaScript/TypeScript реализация корректного равенства — нетривиальная задача.
JavaScript предоставляет два оператора сравнения: == (loose) и === (strict). Ни один из них не подходит для сравнения объектов по значению — оба сравнивают ссылки:
const a = { x: 1, y: 2 }
const b = { x: 1, y: 2 }
a === b // false (разные ссылки в памяти!)
a == b // false (тоже разные ссылки)
Effect решает эту проблему через трейт Equal — интерфейс структурного равенства.
Проблема равенства в JavaScript
Ссылочное равенство
По умолчанию JavaScript сравнивает объекты по ссылке:
// Примитивы — сравнение по значению
"hello" === "hello" // true
42 === 42 // true
// Объекты — сравнение по ССЫЛКЕ
{ x: 1 } === { x: 1 } // false
[1, 2, 3] === [1, 2, 3] // false
new Date(2024, 0, 1) === new Date(2024, 0, 1) // false
Это означает, что Value Objects, реализованные как обычные объекты, нельзя сравнивать через ===:
class NaiveEmail {
constructor(readonly address: string) {}
}
const email1 = new NaiveEmail("user@example.com")
const email2 = new NaiveEmail("user@example.com")
email1 === email2 // false ❌
email1.address === email2.address // true, но нужно знать про поле
// В Set — дубликаты!
const set = new Set([email1, email2])
set.size // 2 ❌ (должно быть 1)
Попытки решения без Effect
JSON.stringify
// ❌ Хрупко: порядок полей не гарантирован, не работает с Date, Set, Map
JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 }) // false!
Ручная реализация equals
// ❌ Утомительно: нужно для каждого класса
class Money {
constructor(readonly amount: number, readonly currency: string) {}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency
}
}
// Проблема: забудешь обновить equals при добавлении нового поля
// Проблема: не работает с Set, Map, Array.includes
Deep equal из lodash
import { isEqual } from "lodash"
// ❌ Нет типобезопасности, overhead на рефлексию
isEqual({ x: 1 }, { x: 1 }) // true, но медленно
Equal trait в Effect
Что такое Equal
Equal — это трейт (интерфейс) из Effect, который определяет структурное равенство для объектов. Любой объект, реализующий Equal, может быть корректно сравнён через Equal.equals().
import { Equal } from "effect"
// Интерфейс Equal
interface Equal {
[Equal.symbol](that: Equal): boolean
}
Как работает Equal.equals
Функция Equal.equals — универсальный компаратор:
import { Equal } from "effect"
// Для примитивов — обычное ===
Equal.equals(1, 1) // true
Equal.equals("a", "a") // true
Equal.equals(true, true) // true
// Для объектов с Equal trait — структурное сравнение
Equal.equals(someVO1, someVO2) // true если все поля равны
// Для обычных объектов — ссылочное сравнение (fallback)
Equal.equals({ x: 1 }, { x: 1 }) // false (нет Equal trait)
Data.Class — автоматический Equal
Data.Class автоматически реализует Equal для всех своих полей:
import { Data, Equal } from "effect"
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 через Equal
Equal.equals(p1, p2) // true ✅
Equal.equals(p1, p3) // false ✅
// Важно: === всё ещё сравнивает ссылки
p1 === p2 // false (разные объекты в памяти)
Глубокое сравнение
Data.Class выполняет глубокое сравнение — рекурсивно сравнивает вложенные структуры:
import { Data, Equal } from "effect"
class Address extends Data.Class<{
readonly city: string
readonly street: string
readonly zip: string
}> {}
class Person extends Data.Class<{
readonly name: string
readonly address: Address
}> {}
const person1 = new Person({
name: "Alice",
address: new Address({ city: "Moscow", street: "Tverskaya", zip: "125009" })
})
const person2 = new Person({
name: "Alice",
address: new Address({ city: "Moscow", street: "Tverskaya", zip: "125009" })
})
// Глубокое сравнение — проверяет и вложенный Address
Equal.equals(person1, person2) // true ✅
Schema.Class — Equal + Schema
Schema.Class тоже автоматически реализует Equal:
import { Schema, Equal } from "effect"
class Money extends Schema.Class<Money>("Money")({
amountInMinorUnits: Schema.Number.pipe(Schema.int()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {}
const m1 = new Money({ amountInMinorUnits: 1050, currency: "USD" })
const m2 = new Money({ amountInMinorUnits: 1050, currency: "USD" })
const m3 = new Money({ amountInMinorUnits: 1050, currency: "EUR" })
Equal.equals(m1, m2) // true (одинаковые поля)
Equal.equals(m1, m3) // false (разные валюты)
Hash trait — спутник Equal
Почему Hash важен
В JavaScript Set и Map используют === (ссылочное сравнение) для ключей. Это означает, что два Value Object с одинаковыми значениями будут считаться разными ключами:
const m1 = new Money({ amountInMinorUnits: 1050, currency: "USD" })
const m2 = new Money({ amountInMinorUnits: 1050, currency: "USD" })
// Нативный Set — ссылочное сравнение
const set = new Set([m1, m2])
set.size // 2 ❌ (должно быть 1!)
// Нативный Map — ссылочное сравнение
const map = new Map()
map.set(m1, "first")
map.get(m2) // undefined ❌ (должно быть "first")
Hash trait
Hash — трейт, вычисляющий числовой хеш-код объекта. Правило: если Equal.equals(a, b) === true, то Hash.hash(a) === Hash.hash(b).
import { Hash, Equal } from "effect"
// Hash автоматически реализован в Data.Class и Schema.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 })
Hash.hash(p1) === Hash.hash(p2) // true (Equal => same hash)
Hash.hash(p1) === Hash.hash(p3) // false (скорее всего, но не гарантировано)
Контракт Equal + Hash
Это фундаментальный контракт, который должен всегда соблюдаться:
Если Equal.equals(a, b) === true, то Hash.hash(a) === Hash.hash(b)
Если Hash.hash(a) !== Hash.hash(b), то Equal.equals(a, b) === false
Обратное НЕ гарантировано:
Если Hash.hash(a) === Hash.hash(b), Equal.equals(a, b) может быть false
(коллизия хешей)
Branded Types и Equal
Branded типы (Schema.brand) — это примитивы, поэтому для них === работает корректно:
import { Schema } from "effect"
const TodoId = Schema.String.pipe(
Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type
const id1 = Schema.decodeUnknownSync(TodoId)("abc-123")
const id2 = Schema.decodeUnknownSync(TodoId)("abc-123")
// Branded type — это всё ещё string
id1 === id2 // true ✅ (примитивы сравниваются по значению)
// Equal.equals тоже работает
Equal.equals(id1, id2) // true
Вывод: для Simple VO (branded примитивов) === достаточно. Для Composite VO (Data.Class, Schema.Class) необходим Equal.equals.
Паттерны сравнения Value Objects
Паттерн 1: Прямое сравнение
import { Equal } from "effect"
const isSamePoint = (a: Point, b: Point): boolean =>
Equal.equals(a, b)
Паттерн 2: Сравнение в условиях
import { Equal } from "effect"
class Priority extends Data.Class<{
readonly level: "low" | "medium" | "high" | "critical"
}> {}
const HIGH = new Priority({ level: "high" })
const CRITICAL = new Priority({ level: "critical" })
const isUrgent = (priority: Priority): boolean =>
Equal.equals(priority, HIGH) || Equal.equals(priority, CRITICAL)
Паттерн 3: Поиск в массиве
import { Equal, ReadonlyArray } from "effect"
const todos: ReadonlyArray<Todo> = [/* ... */]
const targetId: TodoId = /* ... */
// Используем функцию из Effect для поиска с Equal
const found = ReadonlyArray.findFirst(
todos,
(todo) => Equal.equals(todo.id, targetId)
)
Паттерн 4: Удаление дубликатов
import { ReadonlyArray, Equal, Hash } from "effect"
const emails: ReadonlyArray<Email> = [
createEmailSync("a@b.com"),
createEmailSync("c@d.com"),
createEmailSync("a@b.com") // дубликат
]
// Для branded типов (примитивов) — обычный Set работает
const unique = [...new Set(emails)]
// unique = ["a@b.com", "c@d.com"]
Паттерн 5: Кастомный компаратор
Иногда нужно сравнение не по всем полям:
import { Data, Equal, Hash } from "effect"
// VO, который сравнивается только по businessKey
class Product extends Data.Class<{
readonly sku: string
readonly name: string
readonly price: number
}> {
// Переопределяем Equal — сравниваем только по SKU
[Equal.symbol](that: Equal.Equal): boolean {
if (that instanceof Product) {
return this.sku === that.sku
}
return false
}
// Hash тоже должен зависеть только от SKU
[Hash.symbol](): number {
return Hash.string(this.sku)
}
}
const p1 = new Product({ sku: "ABC-001", name: "Widget", price: 10 })
const p2 = new Product({ sku: "ABC-001", name: "Widget v2", price: 15 })
Equal.equals(p1, p2) // true (одинаковый SKU)
Осторожно: переопределение Equal для Value Object — редкая необходимость. По умолчанию сравнение по всем полям — правильное поведение для VO. Кастомный Equal обычно нужен для Entity (сравнение по id).
Data.struct и Data.tuple — равенство “из коробки”
Для быстрого создания сравнимых значений без класса:
import { Data, Equal } from "effect"
// Data.struct — объект с Equal
const point1 = Data.struct({ x: 1, y: 2 })
const point2 = Data.struct({ x: 1, y: 2 })
Equal.equals(point1, point2) // true
// Data.tuple — кортеж с Equal
const range1 = Data.tuple(1, 10)
const range2 = Data.tuple(1, 10)
Equal.equals(range1, range2) // true
// Data.array — массив с Equal
const arr1 = Data.array([1, 2, 3])
const arr2 = Data.array([1, 2, 3])
Equal.equals(arr1, arr2) // true
Equal в контексте тестирования
Использование Equal в assertions
import { describe, it, expect } from "bun:test"
import { Equal } from "effect"
describe("Money equality", () => {
it("equal money with same values", () => {
const m1 = Money.of(10.50, "USD")
const m2 = Money.of(10.50, "USD")
expect(Equal.equals(m1, m2)).toBe(true)
})
it("different amount means not equal", () => {
const m1 = Money.of(10, "USD")
const m2 = Money.of(20, "USD")
expect(Equal.equals(m1, m2)).toBe(false)
})
it("different currency means not equal", () => {
const m1 = Money.of(10, "USD")
const m2 = Money.of(10, "EUR")
expect(Equal.equals(m1, m2)).toBe(false)
})
})
Тестирование Equal-контракта
Для каждого Value Object стоит проверять основные свойства равенства:
import { describe, it, expect } from "bun:test"
import { Equal, Hash } from "effect"
// Обобщённый тест для Equal-контракта
const testEqualContract = <T extends Equal.Equal>(
name: string,
create: () => T,
createDifferent: () => T
) => {
describe(`${name} Equal contract`, () => {
// Рефлексивность: a === a
it("reflexive: x equals x", () => {
const x = create()
expect(Equal.equals(x, x)).toBe(true)
})
// Симметричность: a === b => b === a
it("symmetric: x equals y implies y equals x", () => {
const x = create()
const y = create()
expect(Equal.equals(x, y)).toBe(Equal.equals(y, x))
})
// Транзитивность: a === b && b === c => a === c
it("transitive", () => {
const x = create()
const y = create()
const z = create()
if (Equal.equals(x, y) && Equal.equals(y, z)) {
expect(Equal.equals(x, z)).toBe(true)
}
})
// Неравные объекты
it("not equal to different value", () => {
const x = create()
const y = createDifferent()
expect(Equal.equals(x, y)).toBe(false)
})
// Hash consistency
it("equal objects have same hash", () => {
const x = create()
const y = create()
if (Equal.equals(x, y)) {
expect(Hash.hash(x)).toBe(Hash.hash(y))
}
})
})
}
// Применение к нашим VO
testEqualContract(
"Money",
() => Money.of(10, "USD"),
() => Money.of(20, "EUR")
)
testEqualContract(
"Point",
() => new Point({ x: 1, y: 2 }),
() => new Point({ x: 3, y: 4 })
)
Performance: когда Equal имеет значение
Оптимизация через ссылочное сравнение
Equal.equals в Effect оптимизирован: сначала проверяет === (ссылки), и только если ссылки разные — переходит к структурному сравнению:
const m = Money.of(10, "USD")
// Быстрый путь: ссылочное сравнение
Equal.equals(m, m) // true (мгновенно, без сравнения полей)
// Медленный путь: структурное сравнение
const m2 = Money.of(10, "USD")
Equal.equals(m, m2) // true (сравнивает все поля)
Мемоизация через Hash
Hash позволяет быстро отфильтровать неравные объекты до дорогого полного сравнения:
1. Вычислить hash(a) и hash(b)
2. Если хеши разные → объекты точно не равны (быстрый отказ)
3. Если хеши совпадают → полное сравнение через Equal (может быть коллизия)
Резюме
| Концепция | Описание |
|---|---|
| Equal trait | Интерфейс для структурного сравнения объектов |
| Hash trait | Хеш-функция, согласованная с Equal |
| Data.Class | Автоматические Equal + Hash |
| Schema.Class | Автоматические Equal + Hash + Schema |
=== | Достаточно для branded примитивов |
Equal.equals | Необходим для составных VO (Data.Class, Schema.Class) |
| Контракт | equal → same hash; different hash → not equal |
Правильная реализация равенства — фундамент для работы Value Objects в коллекциях, кешах и алгоритмах сравнения. В следующей статье мы разберём, как использовать Value Objects в коллекциях Effect: HashMap, HashSet и другие.