Раздел 25 · Effect-TS

Стандартная библиотека Effect: равенство, коллекции, порядок, деньги, время

middle-senior~80 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Стандартная библиотека 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. Раздел 1, равенство: Equal, Data.Class, Data.taggedEnum.
  2. Раздел 2, коллекции с настоящим равенством: HashSet, HashMap.
  3. Раздел 3, модуль Array и что стало с Chunk.
  4. Раздел 4, Order и Equivalence как значения.
  5. Раздел 5, деньги: BigDecimal.
  6. Раздел 6, время: DateTime и Duration.
  7. Раздел 7, мелочи, которые экономят строки: Struct, Tuple, Redacted.
  8. Раздел 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, линтер, профилирование и главное умение эффектиста, читать длинные ошибки типов.

Полезно перечитать: