Стандартная библиотека Effect: равенство, коллекции, порядок, деньги, время
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Стандартная библиотека Effect: равенство, коллекции, порядок, деньги, время
Сцена · три бага, которые ты точно писал
0.1 + 0.2 === 0.3; // false
new Set([{ id: 1 }, { id: 1 }]).size; // 2
new Date('2026-03-29T02:30:00').getHours(); // зависит от часового пояса машины
Все три из одного корня: в JavaScript равенство ссылочное, числа двоичные с плавающей точкой, а дата это момент времени без пояса. Обойти это можно, все и обходят: сравнивают через JSON.stringify, хранят копейки целыми числами, тащат dayjs с плагином часовых поясов.
Effect предлагает другой путь: типы, которые ведут себя правильно по построению. Мы уже пользовались частью из них, не разбирая систематически: Option, Result, Duration, Data.TaggedError. Этот урок закрывает остальное и, что важнее, объясняет, когда это брать не надо.
Урок устроен как справочник с примерами. Читать подряд необязательно, но раздел про равенство прочитай первым: на нём держится половина остального.
Карта урока · что заберёшь домой
Восемь разделов:
- Раздел 1, равенство:
Equal,Data.Class,Data.taggedEnum. - Раздел 2, коллекции с настоящим равенством:
HashSet,HashMap. - Раздел 3, модуль
Arrayи что стало сChunk. - Раздел 4,
OrderиEquivalenceкак значения. - Раздел 5, деньги:
BigDecimal. - Раздел 6, время:
DateTimeиDuration. - Раздел 7, мелочи, которые экономят строки:
Struct,Tuple,Redacted. - Раздел 8, когда всё это брать не надо.
Раздел 1 · Равенство
Структурное равенство в Effect включено по умолчанию. Просто вызови Equal.equals:
import { Equal } from 'effect';
Equal.equals({ x: 1, y: 2 }, { x: 1, y: 2 }); // true
Equal.equals([1, [2, 3]], [1, [2, 3]]); // true
Equal.equals(new Set([1, 2]), new Set([1, 2])); // true
Это заметная перемена по сравнению с третьей версией, где Equal.equals для обычных объектов сравнивал ссылки, и глубокое сравнение приходилось включать отдельно. Теперь по значению сравниваются объекты, массивы, Map, Set, Date и RegExp. Заодно Equal.equals(NaN, NaN) теперь true, что для сравнения структур практичнее, чем буква стандарта IEEE.
Если для конкретного значения нужно именно ссылочное равенство, есть явный отказ: Equal.byReference(obj) возвращает обёртку, которая сравнивается по ссылке.
Отдельные классы для равенства теперь не обязательны, но Data.Class никуда не делся и остаётся полезным:
import { Data, Equal } from 'effect';
class Point extends Data.Class<{ x: number; y: number }> {}
const a = new Point({ x: 1, y: 2 });
const b = new Point({ x: 1, y: 2 });
Equal.equals(a, b); // true
a === b; // false
Что он даёт сверх бесплатного структурного равенства: именованный тип вместо анонимного литерала, конструктор из объекта и осмысленное поведение в instanceof. То есть теперь это выбор про моделирование домена, а не про то, будет ли работать сравнение.
Для суммы вариантов есть Data.taggedEnum:
const State = Data.taggedEnum<
| { readonly _tag: 'Up'; readonly since: number }
| { readonly _tag: 'Down'; readonly reason: string }
>();
const state = State.Down({ reason: 'timeout' });
// state._tag === 'Down', state.reason === 'timeout'
Получаются конструкторы (State.Up, State.Down), структурное равенство и готовность к Match.value из 03 · Tagged-ошибки. Ровно то же семейство, что Data.TaggedError, только без наследования от Error.
Практическое правило: Data берут для доменных понятий, у которых есть имя. Точка, состояние монитора, деньги. Входные DTO и промежуточные объекты оставляй обычными литералами: сравнивать и класть в хеш-коллекции их и так можно.
Раздел 2 · Коллекции, которые понимают равенство
import { HashMap, HashSet } from 'effect';
const set = HashSet.make(a, b, new Point({ x: 3, y: 4 }));
HashSet.size(set); // 2, потому что a и b это одно и то же значение
const map = HashMap.make([a, 'первая'] as const, [b, 'вторая'] as const);
HashMap.size(map); // 1
HashMap.get(map, a); // Option.some('вторая'), последняя запись победила
Сравни с обычным Set: new Set([a, b]) дал бы размер 2, потому что это разные ссылки. Это тот самый баг из сцены, только теперь его нет. И работает это не только для Data-классов: HashSet.make({ id: 1 }, { id: 1 }) тоже схлопнется в один элемент, потому что хеш-коллекции опираются на Equal.equals, а он теперь структурный.
Три отличия от встроенных Map и Set, о которых стоит знать.
Ключом может быть составное значение. Пара “адрес плюс регион”, кортеж, доменный объект. Без структурного равенства это делают через строковый ключ `${url}|${region}` и потом парсят обратно, теряя типы.
Они неизменяемые. HashMap.set возвращает новую карту, старая остаётся. Внутри persistent-структура, поэтому это не копирование всей карты на каждое изменение.
Возвращают Option. HashMap.get даёт Option.some или Option.none, а не undefined. Разница видна ровно в тот момент, когда в карте лежит значение undefined.
Соседи из того же семейства: HashSet, HashMap, MutableHashMap (изменяемая версия для горячих циклов), SortedSet и SortedMap (упорядоченные, принимают Order из Раздела 4), Trie (префиксное дерево).
Раздел 3 · Модуль Array и что стало с Chunk
Начнём с приятной новости. Chunk мы видели в 09 · Stream: неизменяемая последовательность, оптимизированная под конкатенацию. В третьей версии стримы отдавали наружу именно её, и в пользовательском коде постоянно приходилось конвертировать туда-обратно.
В четвёртой версии этого больше нет. Stream.runCollect, Stream.grouped, Stream.groupedWithin, приёмники из 23 · Sink, все они отдают обычный Array. Модуль Chunk остался и работает, но стал внутренней деталью: он тебе понадобится, только если ты сам строишь структуру с очень частой конкатенацией.
Практическое правило стало проще некуда: обычный массив, если у тебя нет причины взять что-то другое.
Тем нужнее модуль Array из effect, и его недооценивают. Это набор функций над обычными массивами, с правильными типами:
import { Array as Arr } from 'effect';
Arr.groupBy([1, 2, 3, 4, 5], (n) => (n % 2 === 0 ? 'чёт' : 'нечет'));
// { 'нечет': [1, 3, 5], 'чёт': [2, 4] }
Arr.partition([1, 2, 3, 4], (n) => n % 2 === 0);
// [[1, 3], [2, 4]]
Arr.findFirst([1, 2, 3], (n) => n > 1);
// Option.some(2)
Что здесь лучше встроенных методов.
Arr.findFirst возвращает Option, а не T | undefined. С noUncheckedIndexedAccess в нашем tsconfig.json это экономит проверки.
Arr.head и Arr.last тоже возвращают Option, поэтому array[0] с последующим ! исчезает из кода.
Arr.isArrayNonEmpty сужает тип до непустого массива, и дальше Arr.headNonEmpty уже не возвращает Option, потому что пустоты быть не может. Тип знает больше, проверок меньше. Имя читается непривычно (сначала сущность, потом свойство), но так устроены все guard-ы v4: isArrayEmpty, isReadonlyArrayNonEmpty и так далее.
Импортируется он обычно как import { Array as Arr } from 'effect', чтобы не затенять глобальный Array.
Раздел 4 · Order и Equivalence это значения
Компаратор в JavaScript пишут функцией на месте: rows.sort((a, b) => a.score - b.score). Работает, пока ключ один. На трёх ключах получается лесенка из ||, которую невозможно ни прочитать, ни переиспользовать.
В Effect порядок это значение, которое собирается из кусочков:
import { Array as Arr, Order } from 'effect';
type Row = { readonly name: string; readonly score: number };
const byScore = Order.mapInput(Order.flip(Order.Number), (row: Row) => row.score);
const byName = Order.mapInput(Order.String, (row: Row) => row.name);
Arr.sort(rows, Order.combine(byScore, byName));
// сначала по убыванию очков, при равенстве по имени
// [{в,9}, {а,5}, {б,5}]
Читается сверху вниз: Order.Number это порядок на числах, flip разворачивает его, mapInput объясняет, как достать число из строки таблицы, combine задаёт приоритет ключей. Каждый кусок отдельно тестируется и переиспользуется.
Пара мелочей по именам в v4. Готовые порядки пишутся с большой буквы (Order.String, Order.Number, Order.Boolean, Order.Date), потому что это значения-константы, а не функции. Разворот называется flip, а не reverse. Проверки переехали на приставку is: Order.isGreaterThan, Order.isBetween.
Дальше эти же значения работают везде, где нужен порядок: Arr.sort, SortedMap, Order.min, Order.max, Order.clamp. Есть и готовые сборщики для составных типов: Order.Struct({...}) и Order.Tuple(...).
Equivalence устроен симметрично, но для равенства:
import { Equivalence } from 'effect';
const sameRow = Equivalence.Struct({
name: Equivalence.String,
score: Equivalence.Number,
});
Зачем он нужен, если есть Equal.equals. Затем, что равенство бывает контекстным: два пользователя равны по идентификатору для дедупликации, но по всем полям для проверки изменений. Equal это одно, встроенное в тип равенство, а Equivalence это сколько угодно разных, объявленных снаружи.
Раздел 5 · Деньги: BigDecimal
import { BigDecimal } from 'effect';
0.1 + 0.2; // 0.30000000000000004
1.1 * 3; // 3.3000000000000003
19.99 * 1.2; // 23.987999999999996
BigDecimal считает в десятичной системе, поэтому таких сюрпризов нет:
const sum = BigDecimal.sum(
BigDecimal.fromStringUnsafe('0.1'),
BigDecimal.fromStringUnsafe('0.2'),
);
BigDecimal.format(sum); // "0.3"
const withTax = BigDecimal.multiply(
BigDecimal.fromStringUnsafe('19.99'),
BigDecimal.fromStringUnsafe('1.2'),
);
BigDecimal.format(withTax); // "23.988"
BigDecimal.format(BigDecimal.round(withTax, { scale: 2, mode: 'half-ceil' })); // "23.99"
Три правила, чтобы не испортить всё на входе и выходе.
Строить из строки, а не из числа. fromStringUnsafe('19.99') точен, fromNumberUnsafe(19.99) уже принимает на вход испорченное значение. Из числа безопасно строить только целые.
Кстати про имена: приставка unsafe в v4 везде переехала в суффикс. Было unsafeFromString, стало fromStringUnsafe. То же правило у DateTime.makeUnsafe, Redacted.wipeUnsafe, HttpServerResponse.jsonUnsafe. Логика в том, что имя начинается с того, что функция делает, а предупреждение идёт следом.
Округлять явно и в одном месте. У BigDecimal.round есть scale и mode (half-ceil, half-even, floor и другие). Банковское округление half-even не прихоть: на больших объёмах half-ceil систематически завышает сумму.
Проверять на границе схемой. BigDecimal живёт внутри домена, а на входе и выходе стоит Schema.BigDecimal (из 02 · Schema), который декодирует строку и кодирует обратно. Так число не превращается в number по дороге в JSON.
Альтернатива, которую все знают: хранить копейки целым числом. Она работает и стоит дешевле по вычислениям, но ломается на процентах, курсах валют и делении. Если в предметной области есть хоть одно умножение на дробь, бери BigDecimal.
Раздел 6 · Время: DateTime
Date в JavaScript изменяемый, без часового пояса и с арифметикой через миллисекунды. DateTime в Effect закрывает всё три беды.
import { DateTime, Duration, Effect, Order } from 'effect';
const program = Effect.gen(function* () {
const now = yield* DateTime.now; // из Clock, значит тестируемо
const utc = DateTime.makeUnsafe('2026-07-29T08:00:00Z');
DateTime.formatIso(utc); // 2026-07-29T08:00:00.000Z
const later = DateTime.add(utc, { hours: 3 });
DateTime.formatIso(later); // 2026-07-29T11:00:00.000Z
Duration.format(Duration.millis(DateTime.distance(utc, later))); // 3h
Order.isGreaterThan(DateTime.Order)(later, utc); // true
DateTime.formatIso(DateTime.startOf(utc, 'day')); // 2026-07-29T00:00:00.000Z
});
Две детали из v4. DateTime.distance отдаёт знаковое число миллисекунд, а не Duration, поэтому обернуть его в Duration.millis (и, если знак не нужен, взять модуль) придётся самому. А сравнения живут не в DateTime, а в общем модуле Order: у типа есть готовое значение DateTime.Order, и через него работают isGreaterThan, min, max и всё остальное из Раздела 4. Одно понятие вместо дубликата сравнений в каждом модуле.
Что тут принципиально.
DateTime.now берёт время из Clock, а значит подчиняется TestClock из 13 · Testing. Любая логика на датах тестируется без ожидания и без подмены глобального Date.
Арифметика в человеческих единицах. DateTime.add(utc, { hours: 3, days: 1 }) вместо сложения миллисекунд. Тут же subtract, startOf, endOf, nearest.
Часовой пояс это часть значения. DateTime.Utc и DateTime.Zoned это разные типы:
const msk = yield* DateTime.setZoneNamed(utc, 'Europe/Moscow');
DateTime.format(msk, { dateStyle: 'short', timeStyle: 'short' }); // 7/29/26, 11:00 AM
Мелочь, на которой спотыкаются: setZoneNamed возвращает Option<Zoned>, потому что имя пояса может быть неизвестным. Внутри Effect.gen его можно писать через yield* как обычный эффект (Option там раскрывается сам, а None превращается в NoSuchElementError), но в чистом коде это Option, и обработать его придётся явно.
На границе с внешним миром работают Schema.DateTimeUtc и Schema.BigDecimal из 02 · Schema: в JSON строка, в домене нормальный тип, туда и обратно без потерь.
Отсюда следует главная практика работы со временем: храни и считай в UTC, переводи в пояс пользователя только на отображении. DateTime делает это правило типизированным, а не устным: Zoned не подсунешь туда, где ждут Utc.
Рядом живёт Duration, который встречался в каждом втором уроке: Duration.seconds(30), Duration.format, Duration.sum, Duration.greaterThan. Он же принимает человеческие строки, поэтому Effect.timeout('5 seconds') работает без конверсий.
Раздел 7 · Мелочи, которые экономят строки
Struct работает с объектами как с данными:
import { Struct } from 'effect';
Struct.pick({ a: 1, b: 2, c: 3 }, ['a', 'c']); // { a: 1, c: 3 }
Struct.omit({ a: 1, b: 2, c: 3 }, ['b']); // { a: 1, c: 3 }
Struct.evolve({ a: 1, b: 'x' }, { a: (n) => n + 1 }); // { a: 2, b: 'x' }
Ключи передаются массивом, а не списком аргументов. Это то же изменение, что у Schema.Literals и Schema.Union: там, где раньше был переменный список, теперь один явный массив.
Тот же Struct работает и со схемами, через .mapFields(...) из 21 · Schema, второй заход. Выучив один модуль, ты получаешь и проекции обычных объектов, и проекции схем.
Равенство и порядок для структуры собираются в своих модулях: Equivalence.Struct({...}) и Order.Struct({...}), из равенств и порядков полей.
Tuple делает то же для кортежей: Tuple.make, Tuple.swap, Tuple.mapFirst, плюс Equivalence.Tuple и Order.Tuple. Пригождается там, где пары ходят как ключи.
Redacted разбирали в 20 · Config, секреты и platform: обёртка, которая не даёт секрету попасть в лог.
Predicate собирает предикаты, как Order собирает порядки: Predicate.and, Predicate.or, Predicate.not, плюс готовые проверки типа (Predicate.isString, Predicate.isRecord). Полезно, когда условие фильтрации собирается из настроек, а не пишется буквально.
Раздел 8 · Когда всё это брать не надо
Самая частая ошибка после такого урока это переписать проект на HashMap и Data целиком. Не надо.
Обычный Map подходит, когда ключ это строка или число. Структурное равенство там ничего не добавляет, а встроенный Map быстрее и понятнее любому, кто откроет файл.
Обычный массив подходит почти везде. После того как стримы перестали отдавать Chunk, поводов брать его в прикладном коде почти не осталось.
number подходит для всего, что не деньги и не точные измерения: счётчики, размеры, идентификаторы, латентность в миллисекундах.
Date подходит на самой границе: пришёл Date из библиотеки, отдал Date в библиотеку. Внутри домена держи DateTime, а конверсию делай в одном месте.
Общее правило, которое стоит запомнить: специализированный тип берут ради свойства, которого не хватает. Не хватает структурного равенства, значит Data и HashMap. Не хватает десятичной точности, значит BigDecimal. Не хватает пояса и тестируемости, значит DateTime. Если свойства хватает, стандартного типа достаточно, и это не компромисс, а правильный выбор.
Pulse · вклад этого урока
Четыре точечных изменения, каждое ради конкретного свойства.
Доменные значения переезжают на Data.Class. Target, ProbeResult, SlaState перестают сравниваться по ссылке, и дедупликация целей превращается в HashSet вместо ручного обхода массива с some.
Состояние монитора становится Data.taggedEnum с вариантами Up, Degraded, Down. Обработка через Match.value из 03 · Tagged-ошибки становится исчерпывающей: добавишь вариант, компилятор покажет все места.
Сортировка списка мониторов на дашборде собирается из Order: сначала упавшие, потом по числу подряд идущих ошибок, потом по имени. Три mapInput плюс два combine вместо компаратора на пятнадцать строк.
Отчёт по дням переходит на DateTime: границы суток берутся startOf('day') в поясе из конфигурации, а не вычитанием миллисекунд. Заодно тест на переход летнего времени перестаёт падать раз в полгода.
Денег в Pulse нет, поэтому BigDecimal тут не появляется. Это тоже часть урока: не тащи тип, ради которого в проекте нет задачи.
Финал · чек-лист
ДЗ
Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-24.
Дальше
- 25 · Инструменты и отладка. Финал раздела: LSP-плагин, devtools, линтер, профилирование и главное умение эффектиста, читать длинные ошибки типов.
Полезно перечитать:
- 13 · Testing и code style, раздел про
Data. Теперь видно, чтоData.TaggedErrorэто частный случай общего семейства. - 03-js · 03 Объекты, массивы, прототипы, про то, откуда в JavaScript ссылочное равенство и почему его нельзя было сделать иначе.