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>>()
)
Когда какую коллекцию использовать
| Коллекция | Ключи | Ситуация |
|---|---|---|
HashMap | Value Objects (Data.Class, Schema.Class) | Индексы, кеши, lookup |
HashSet | Value 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))
Резюме
- Нативные коллекции (
Set,Map) используют===для объектов — Value Objects работают некорректно. - HashMap из Effect использует
Equal+Hash— Value Objects работают как ключи корректно. - HashSet из Effect — множество с поддержкой Equal для удаления дубликатов.
- Для branded примитивов достаточно нативных
Set/Map. - Для составных VO (Data.Class, Schema.Class) необходимы
HashMap/HashSet. - Все коллекции Effect иммутабельны — модификация возвращает новую коллекцию.
В следующей статье мы соберём все знания воедино и создадим Value Objects для нашего Todo-приложения: TodoTitle, Priority, DueDate.