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

Value Objects в коллекциях: HashMap с кастомным Equal/Hash

Проблема нативных Set/Map с Value Objects. HashMap и HashSet из Effect — коллекции с поддержкой Equal/Hash. Полный API: создание, чтение, модификация, итерация. Практические паттерны: индекс по VO, кеш с VO-ключами, уникальные теги, подсчёт частот, группировка. Иммутабельность и structural sharing. Интеграция с Effect Stream.

Введение

В предыдущей статье мы разобрали, как Equal и Hash обеспечивают корректное сравнение Value Objects. Теперь мы увидим, зачем это действительно нужно — при использовании Value Objects в коллекциях.

Стандартные коллекции JavaScript (Set, Map, Array) используют ссылочное сравнение (===) для объектов. Это означает, что два Value Object с одинаковыми значениями будут считаться разными элементами. Effect предоставляет коллекции, которые используют Equal и Hash для корректной работы с Value Objects.


Проблема: нативные коллекции и Value Objects

Set

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 })

// Equal говорит: p1 === p2
Equal.equals(p1, p2)  // true

// Но нативный Set так не думает:
const nativeSet = new Set([p1, p2])
nativeSet.size  // 2 ❌ (должно быть 1!)

// Нативный Set использует ===
p1 === p2  // false (разные объекты в памяти)

Map

const m1 = new Money({ amountInMinorUnits: 1050, currency: "USD" })
const m2 = new Money({ amountInMinorUnits: 1050, currency: "USD" })

const nativeMap = new Map<Money, string>()
nativeMap.set(m1, "ten fifty")
nativeMap.get(m2)  // undefined ❌ (должно быть "ten fifty")

Array методы

const points = [
  new Point({ x: 1, y: 2 }),
  new Point({ x: 3, y: 4 }),
  new Point({ x: 1, y: 2 })
]

const target = new Point({ x: 1, y: 2 })

points.includes(target)  // false ❌ (использует ===)
points.indexOf(target)   // -1 ❌

Решение: HashMap из Effect

Что такое HashMap

HashMap из Effect — это иммутабельная хеш-таблица, которая использует Equal и Hash для ключей. Это означает, что Value Objects работают как ключи корректно:

import { HashMap, Data, Equal } from "effect"

class Point extends Data.Class<{
  readonly x: number
  readonly y: number
}> {}

// Создание HashMap
const map = HashMap.make(
  [new Point({ x: 1, y: 2 }), "A"],
  [new Point({ x: 3, y: 4 }), "B"]
)

// Поиск по Value Object — работает через Equal!
const result = HashMap.get(
  map,
  new Point({ x: 1, y: 2 })  // Новый объект, но Equal считает его тем же
)
// result = Option.some("A") ✅

Основные операции HashMap

import { HashMap, Option, pipe } from "effect"

// === Создание ===

// Пустая HashMap
const empty = HashMap.empty<Point, string>()

// Из пар ключ-значение
const map1 = HashMap.make(
  [new Point({ x: 0, y: 0 }), "origin"],
  [new Point({ x: 1, y: 0 }), "right"],
  [new Point({ x: 0, y: 1 }), "up"]
)

// Из iterable
const map2 = HashMap.fromIterable([
  [new Point({ x: 0, y: 0 }), "origin"] as const,
  [new Point({ x: 1, y: 0 }), "right"] as const,
])

// === Чтение ===

// get — возвращает Option
const value = HashMap.get(map1, new Point({ x: 0, y: 0 }))
// Option.some("origin")

// has — проверка наличия
const exists = HashMap.has(map1, new Point({ x: 0, y: 0 }))
// true

// size
HashMap.size(map1)  // 3

// === Модификация (возвращает новую HashMap) ===

// set — добавить или обновить
const map3 = HashMap.set(
  map1,
  new Point({ x: 2, y: 0 }),
  "far right"
)

// Обновление существующего ключа
const map4 = HashMap.set(
  map1,
  new Point({ x: 0, y: 0 }),
  "ORIGIN"  // заменяет "origin"
)

// remove — удалить
const map5 = HashMap.remove(
  map1,
  new Point({ x: 0, y: 0 })
)

// modify — обновить значение если есть
const map6 = HashMap.modify(
  map1,
  new Point({ x: 0, y: 0 }),
  (value) => value.toUpperCase()
)

// === Итерация ===

// forEach
HashMap.forEach(map1, (value, key) => {
  console.log(`(${key.x}, ${key.y}) => ${value}`)
})

// map — трансформация значений
const uppercased = HashMap.map(map1, (value) => value.toUpperCase())

// filter
const filtered = HashMap.filter(map1, (value) => value.length > 2)

// reduce
const allValues = HashMap.reduce(
  map1,
  "" as string,
  (acc, value) => acc + value + ", "
)

// toEntries
const entries = HashMap.toEntries(map1)
// Array<[Point, string]>

// keys и values
const keys = HashMap.keys(map1)    // Iterable<Point>
const values = HashMap.values(map1) // Iterable<string>

HashSet — множество Value Objects

Основные операции

import { HashSet, Data, Equal, pipe } from "effect"

class Tag extends Data.Class<{ readonly name: string }> {}

// Создание
const set1 = HashSet.make(
  new Tag({ name: "urgent" }),
  new Tag({ name: "bug" }),
  new Tag({ name: "urgent" })  // Дубликат — будет удалён!
)

HashSet.size(set1)  // 2 ✅ (дубликат убран через Equal)

// Добавление
const set2 = HashSet.add(set1, new Tag({ name: "feature" }))

// Проверка наличия — через Equal
const has = HashSet.has(set1, new Tag({ name: "urgent" }))
// true ✅ (новый объект, но Equal считает равным)

// Удаление
const set3 = HashSet.remove(set1, new Tag({ name: "bug" }))

// Операции над множествами
const setA = HashSet.make(
  new Tag({ name: "a" }),
  new Tag({ name: "b" }),
  new Tag({ name: "c" })
)
const setB = HashSet.make(
  new Tag({ name: "b" }),
  new Tag({ name: "c" }),
  new Tag({ name: "d" })
)

// Объединение
const union = HashSet.union(setA, setB)
// { a, b, c, d }

// Пересечение
const intersection = HashSet.intersection(setA, setB)
// { b, c }

// Разность
const difference = HashSet.difference(setA, setB)
// { a }

// Итерация
HashSet.forEach(set1, (tag) => console.log(tag.name))

// Преобразование
const names = HashSet.map(set1, (tag) => tag.name)

// Фильтрация
const urgent = HashSet.filter(set1, (tag) => tag.name === "urgent")

// toArray
const array = Array.from(set1)

Практические паттерны

Паттерн 1: Индекс по Value Object

import { HashMap, Schema, Option, pipe } from "effect"

// Индекс задач по приоритету
class Priority extends Data.Class<{
  readonly level: "low" | "medium" | "high" | "critical"
}> {}

type TodoIndex = HashMap.HashMap<Priority, ReadonlyArray<Todo>>

const buildIndex = (todos: ReadonlyArray<Todo>): TodoIndex =>
  todos.reduce(
    (index, todo) => {
      const priority = new Priority({ level: todo.priority })
      const existing = pipe(
        HashMap.get(index, priority),
        Option.getOrElse(() => [] as ReadonlyArray<Todo>)
      )
      return HashMap.set(index, priority, [...existing, todo])
    },
    HashMap.empty<Priority, ReadonlyArray<Todo>>()
  )

// Поиск
const highPriorityTodos = pipe(
  buildIndex(allTodos),
  (index) => HashMap.get(index, new Priority({ level: "high" })),
  Option.getOrElse(() => [] as ReadonlyArray<Todo>)
)

Паттерн 2: Кеш с Value Object ключами

import { HashMap, Option, pipe, Effect, Ref } from "effect"

class CacheKey extends Data.Class<{
  readonly entityType: string
  readonly entityId: string
}> {}

// Иммутабельный кеш с VO-ключами через Ref
const createCache = <V>() =>
  Effect.gen(function* () {
    const state = yield* Ref.make(HashMap.empty<CacheKey, V>())

    const get = (key: CacheKey): Effect.Effect<Option.Option<V>> =>
      Ref.get(state).pipe(
        Effect.map((cache) => HashMap.get(cache, key))
      )

    const set = (key: CacheKey, value: V): Effect.Effect<void> =>
      Ref.update(state, (cache) => HashMap.set(cache, key, value))

    const invalidate = (key: CacheKey): Effect.Effect<void> =>
      Ref.update(state, (cache) => HashMap.remove(cache, key))

    return { get, set, invalidate } as const
  })

Паттерн 3: Уникальные теги задач

import { HashSet, Data } from "effect"

class TodoTag extends Data.Class<{
  readonly name: string
  readonly color: string
}> {}

// В доменной модели Todo
class Todo extends Data.Class<{
  readonly id: string
  readonly title: string
  readonly tags: HashSet.HashSet<TodoTag>
}> {}

// Операции над тегами
const addTag = (todo: Todo, tag: TodoTag): Todo =>
  new Todo({
    ...todo,
    tags: HashSet.add(todo.tags, tag)
  })

const removeTag = (todo: Todo, tag: TodoTag): Todo =>
  new Todo({
    ...todo,
    tags: HashSet.remove(todo.tags, tag)
  })

const hasTag = (todo: Todo, tag: TodoTag): boolean =>
  HashSet.has(todo.tags, tag)

// Дубликаты автоматически удаляются!
const todo = new Todo({
  id: "1",
  title: "Fix bug",
  tags: HashSet.make(
    new TodoTag({ name: "bug", color: "red" }),
    new TodoTag({ name: "urgent", color: "orange" }),
    new TodoTag({ name: "bug", color: "red" })  // Дубликат — удалён
  )
})

Паттерн 4: Подсчёт частот

import { HashMap, Option, pipe } from "effect"

const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type

// Подсчёт задач по приоритету
const countByPriority = (
  todos: ReadonlyArray<{ readonly priority: Priority }>
): HashMap.HashMap<Priority, number> =>
  todos.reduce(
    (counts, todo) =>
      HashMap.modify(
        HashMap.has(counts, todo.priority)
          ? counts
          : HashMap.set(counts, todo.priority, 0),
        todo.priority,
        (n) => n + 1
      ),
    HashMap.empty<Priority, number>()
  )

Паттерн 5: Группировка с несколькими VO-ключами

import { HashMap, Data, Option, pipe } from "effect"

// Составной ключ группировки
class GroupKey extends Data.Class<{
  readonly priority: string
  readonly status: string
}> {}

// Группировка задач по двум измерениям
const groupTodos = (
  todos: ReadonlyArray<Todo>
): HashMap.HashMap<GroupKey, ReadonlyArray<Todo>> =>
  todos.reduce(
    (groups, todo) => {
      const key = new GroupKey({
        priority: todo.priority,
        status: todo.completed ? "completed" : "active"
      })
      const existing = pipe(
        HashMap.get(groups, key),
        Option.getOrElse((): ReadonlyArray<Todo> => [])
      )
      return HashMap.set(groups, key, [...existing, todo])
    },
    HashMap.empty<GroupKey, ReadonlyArray<Todo>>()
  )

Когда какую коллекцию использовать

КоллекцияКлючиСитуация
HashMapValue Objects (Data.Class, Schema.Class)Индексы, кеши, lookup
HashSetValue ObjectsУникальные наборы, теги
Map (native)Примитивы, branded typesПростые случаи
Set (native)Примитивы, branded typesУникальные строки/числа
ReadonlyArrayЛюбыеУпорядоченные списки, итерация

Правило: используйте HashMap/HashSet когда ключи — составные Value Objects (Data.Class/Schema.Class). Для branded примитивов подходят нативные Map/Set.


Иммутабельность коллекций

Все коллекции Effect — персистентные иммутабельные структуры данных. Операции модификации возвращают новую коллекцию, оставляя старую без изменений:

import { HashMap } from "effect"

const map1 = HashMap.make(
  [new Point({ x: 0, y: 0 }), "origin"]
)

const map2 = HashMap.set(
  map1,
  new Point({ x: 1, y: 0 }),
  "right"
)

// map1 НЕ изменился!
HashMap.size(map1)  // 1
HashMap.size(map2)  // 2

// Это безопасно для конкурентного доступа
// и соответствует принципу иммутабельности VO

Персистентные структуры данных эффективно переиспользуют общие части (structural sharing), поэтому создание новой коллекции — операция O(log n), а не O(n).


Интеграция с Effect streams и pipeline

import { HashMap, HashSet, Effect, Stream, pipe } from "effect"

// Stream → HashMap
const indexFromStream = <K extends Equal.Equal, V>(
  stream: Stream.Stream<readonly [K, V]>
): Effect.Effect<HashMap.HashMap<K, V>> =>
  pipe(
    stream,
    Stream.runFold(
      HashMap.empty<K, V>(),
      (map, [key, value]) => HashMap.set(map, key, value)
    )
  )

// HashMap → Stream
const streamFromMap = <K extends Equal.Equal, V>(
  map: HashMap.HashMap<K, V>
): Stream.Stream<readonly [K, V]> =>
  Stream.fromIterable(HashMap.toEntries(map))

Резюме

  1. Нативные коллекции (Set, Map) используют === для объектов — Value Objects работают некорректно.
  2. HashMap из Effect использует Equal + Hash — Value Objects работают как ключи корректно.
  3. HashSet из Effect — множество с поддержкой Equal для удаления дубликатов.
  4. Для branded примитивов достаточно нативных Set/Map.
  5. Для составных VO (Data.Class, Schema.Class) необходимы HashMap/HashSet.
  6. Все коллекции Effect иммутабельны — модификация возвращает новую коллекцию.

В следующей статье мы соберём все знания воедино и создадим Value Objects для нашего Todo-приложения: TodoTitle, Priority, DueDate.