Типобезопасный домен: Гексагональная архитектура на базе Effect Равенство Value Objects: Equal trait из Effect
Глава

Равенство 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 и другие.