Testing и code style: TestClock, dual, branded, Do, Data, финальный чек-лист
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Testing и code style: TestClock, dual, branded, Do, Data, финальный чек-лист
Сцена · детерминированное время
Расписание Pulse: монитор бьёт по адресу каждые 30 секунд, через три провала переключается на резервный URL, при таймауте уходит на retry с экспонентой. На честных часах такой кейс прогоняется минут восемь, и в CI ты получаешь либо мигающий тест, либо тяжёлую сборку.
Тест на это правило в Effect занимает 0 мс реального времени. Внутри живёт TestClock, и ты руками сдвигаешь стрелку: yield* TestClock.adjust('30 seconds'). Расписание стреляет, монитор шлёт запрос (на заглушке), счётчик обновляется, контроль возвращается к тебе. Никаких vi.useFakeTimers поверх setTimeout-ов внутри Effect.sleep, никаких флаков из-за честных setTimeout(5).
Это финальный урок серии. Сегодня мы дочиняем тестовую инфраструктуру Pulse, проходим по code-style-чек-листу Effect (dual API, branded, Do, Data) и закрываем серию письменным чек-листом “когда Effect окупается, а когда хватит neverthrow и голого async/await”.
Сцена · что собрали к финалу
Перед тем как писать тесты, оглядись. За двенадцать уроков в pulse-<nick> собрался не просто учебный пример, а небольшой production-ready проект:
src/
errors.ts // 6 tagged-ошибок [03]
config.ts // Url, Interval, MonitorId, PulseConfig через Schema [02]
events.ts // MonitorEvent как Schema.Union [02]
probe.ts // probe(url): Effect<ProbeResult, PulseError, HttpClient> [03]
matching.ts // formatAlert, recordResult [03]
services/
http.ts // HttpClient через Context.Service [04]
storage.ts // Storage, scoped JSONL writer [04]
logger.ts // Logger [04]
sla.ts // circuit-breaker на TxRef [08]
monitor-events.ts // PubSub событий [07]
monitor-events-stream.ts // Stream-обёртка [09]
jsonl-writer.ts // groupedWithin batcher [09]
events-feed.ts // Stream.share с replay [09]
dns-resolver.ts // RequestResolver с batch [10]
schedule.ts // probeWithSchedule, retry+repeat+timeout [11]
main.ts // MainLive [04]
runtime.ts // ManagedRuntime [04]
test-runtime.ts // buildTestRuntime [04]
cli/index.ts // pulse monitor / watch / serve [12]
http/server.ts // HttpRouter на effect/unstable/http [12]
Параметр R живой: в нём сидят HttpClient | Storage | Logger | Sla | MonitorEventsPubSub | EventsFeed | JsonlWriter | DnsCache. Канал E строгий: ни одного unknown, шесть размеченных классов плюс PulseError алиас. CLI и HTTP-сервер делят один ManagedRuntime. Расписание читается одной строкой Schedule.spaced(monitor.interval).pipe(Schedule.jittered, Schedule.upTo({ times: maxRuns })).
Сегодня в pulse-<nick> появятся test/ с честным набором: probe.test.ts, schedule.test.ts, sla.test.ts, events.test.ts. Все тесты детерминированны (it.effect, TestClock, Layer.succeed), ни один не ходит в сеть, ни один не использует vi.useFakeTimers или vi.mock.
Что будет в уроке
- Раздел 1,
@effect/vitestиit.effect. Чем отличается от обёрткиEffect.runPromiseруками. - Раздел 2,
TestClock. Виртуальные часы дляsleep,Schedule,timeout. - Раздел 3, тестовый Layer без
vi.mock.MainTestдля целого графа Pulse. - Раздел 4,
it.layer. Общий рантайм на блок тестов, один и тот жеStorageживёт междуit-ами. - Раздел 5, property-тесты из схемы и тестирование стримов.
- Раздел 6, code style · dual API. Pipe-friendly и data-first одной декларацией.
- Раздел 7, code style · Brand-углубление.
Brand.nominal,Brand.refined,Brand.error, когда без Schema. - Раздел 8, code style ·
Effect.Do. Когда брать вместоEffect.gen. - Раздел 9, code style ·
Data.structural equality,Data.Class,Data.TaggedClass,Equal/Hash. - Pulse · вклад этого урока. Полный
test/, плюс рефактор пары мест изsrc/по code-style guidelines. - Финал серии, чек-лист “когда Effect окупается, когда нет”.
Раздел 1 · @effect/vitest и it.effect
Сцена
Тест на Effect-функцию можно написать руками: await Effect.runPromise(...), expect(...), runtime.dispose() в afterEach. Это работает, но повторяешь обвязку в каждом файле. @effect/vitest, тонкий слой поверх обычного vitest, который сам разворачивает Effect-программу и сам правильно собирает и закрывает Scope.
Идея словами
import { describe, it } from '@effect/vitest';
import { Effect } from 'effect';
import { expect } from 'vitest';
describe('probe', () => {
it.effect('возвращает status и elapsedMs на 200-ответ', () =>
Effect.gen(function* () {
const result = yield* probe('https://github.com');
expect(result.status).toBe(200);
}),
);
});
Сравни с “руками”:
import { describe, it, expect } from 'vitest';
import { Effect } from 'effect';
describe('probe', () => {
it('возвращает status и elapsedMs на 200-ответ', async () => {
const result = await Effect.runPromise(probe('https://github.com'));
expect(result.status).toBe(200);
});
});
Разница на одной строке кажется косметической, но в больших тестах накапливается. it.effect тянет за собой:
- автоматическое разворачивание
EffectвPromise, ошибки попадают в провалvitest-ассерта с полнымCauseи стектрейсом; - собственный
Scope, который закрывается после теста (никакогоruntime.dispose()вafterEach).
Шаг 1 · it.effect против it.live
Оба варианта запускают Effect-программу, разница в том, какие сервисы в ней доступны по умолчанию. it.effect даёт стандартный набор, it.live гарантирует реальные часы и реальное окружение.
it.effect('обычный тест', () =>
Effect.gen(function* () {
const r = yield* probe('https://example.com');
expect(r.status).toBe(200);
}),
);
it.live('нужен настоящий таймер', () =>
Effect.gen(function* () {
yield* Effect.sleep('50 millis'); // ждёт по-настоящему
}),
);
Важная деталь, из-за которой старые тесты ведут себя не так, как ожидалось. Виртуальные часы не подкладываются сами. Раньше it.effect тихо подменял Clock на тестовый, и Effect.sleep('1 hour') завершался мгновенно. Теперь это явный Layer, и его надо попросить:
import { Effect } from 'effect';
import { TestClock } from 'effect/testing';
it.effect('детерминированный тест', () =>
Effect.gen(function* () {
yield* Effect.forkChild(Effect.sleep('1 hour'));
yield* TestClock.adjust('1 hour'); // час прошёл, реального ожидания ноль
}).pipe(Effect.provide(TestClock.layer())),
);
Обмен честный: строчкой больше, зато по коду теста видно, что время тут виртуальное. Раньше это была невидимая магия, и человек, читающий тест, не мог понять, ждёт Effect.sleep по-настоящему или нет. Подробности в Разделе 2.
Тестового генератора случайных чисел отдельным сервисом больше нет: если нужна воспроизводимость, подмешивай свою реализацию Random обычным Layer-ом, как любой другой сервис.
Шаг 2 · Скоуп у тестов встроенный
Если тест открывает ресурс (file handle, scoped Layer, файбер через Effect.forkScoped), ничего дополнительного делать не надо: it.effect уже скоупный и закроет всё после теста.
import { describe, it } from '@effect/vitest';
import { Effect } from 'effect';
import { JsonlWriter } from '~/services/jsonl-writer.ts';
describe('JsonlWriter', () => {
it.effect('пишет события в файл', () =>
Effect.gen(function* () {
const writer = yield* JsonlWriter; // у Writer есть scoped finalizer
yield* writer.append({ _tag: 'ProbeSuccess', /* ... */ });
// после теста finalizer закроет handle
}),
);
});
Отдельного it.scoped в v4 нет, он слился с it.effect. Та же судьба у it.scopedLive, его роль играет it.live. Если переносишь старые тесты, просто замени имена.
Шаг 3 · expect внутри Effect.gen
expect берётся из обычного vitest (@effect/vitest его больше не реэкспортирует). Падение expect приходит наверх теста как обычная vitest-ошибка, не как Effect-defect. То есть expect(...).toBe(...) не нужно оборачивать в Effect.sync, можно звать прямо в Effect.gen:
it.effect('синхронные ассерты внутри gen', () =>
Effect.gen(function* () {
const result = yield* probe('https://x');
expect(result.status).toBe(200);
expect(result.elapsedMs).toBeGreaterThan(0);
}),
);
Если внутри программы тебе нужен программный check (например, утверждение, по которому ветвится дальнейший код), используй Effect.fail или Effect.die, не expect.
Шаг 4 · Effect.result/Effect.exit для проверки ошибок
Если ты хочешь проверить, что программа упала ожидаемой ошибкой, не позволяй ей долететь до vitest, заверни в Effect.result или Effect.exit:
import { describe, it } from '@effect/vitest';
import { Cause, Effect, Exit, Result } from 'effect';
import { expect } from 'vitest';
import { NetworkError } from '~/errors.ts';
import { probe } from '~/probe.ts';
describe('probe', () => {
it.effect('возвращает NetworkError при сетевой ошибке', () =>
Effect.gen(function* () {
const outcome = yield* Effect.result(probe('https://does-not-exist.local'));
expect(Result.isFailure(outcome)).toBe(true);
if (Result.isFailure(outcome)) {
expect(outcome.failure._tag).toBe('NetworkError');
expect((outcome.failure as NetworkError).url).toBe('https://does-not-exist.local');
}
}),
);
it.effect('die ловится через Effect.exit и Cause.hasDies', () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(Effect.die(new Error('boom')));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(Cause.hasDies(exit.cause)).toBe(true);
}
}),
);
});
Effect.result оборачивает успех и Fail в Result<A, E>, но не ловит Die и Interrupt. Effect.exit оборачивает целиком Exit<A, E> со всем Cause, и ловит абсолютно всё.
Два переименования, которые тут видны, стоит запомнить, потому что они встречаются везде. Тип “либо ошибка, либо успех” зовётся Result, а не Either, и поля у него говорящие: .failure и .success вместо .left и .right. Соответственно Effect.either стал Effect.result, а Either.isLeft это Result.isFailure. Это не слепое переименование: left и right приходилось помнить наизусть, а failure и success читаются сами.
Второе: предикаты Cause стали множественными. В v4 Cause плоский, внутри лежит массив причин, поэтому вопрос звучит как “есть ли среди них дефекты”: hasDies, hasFails, hasInterrupts. Проверить отдельную причину можно через isDieReason, isFailReason, isInterruptReason (03 · Tagged-ошибки).
Листинг выше специально написан наивно, и в следующем шаге мы его перепишем. Присмотрись к паре expect плюс if: там спрятан баг, который не виден, пока тест зелёный.
Шаг 5 · Матчер для Result вместо if в тесте
Вот эта конструкция из предыдущего листинга:
expect(Result.isFailure(outcome)).toBe(true);
if (Result.isFailure(outcome)) {
expect(outcome.failure._tag).toBe('NetworkError');
}
Зачем тут if? Только чтобы TypeScript сузил Result<A, E> до ветки Failure и разрешил дотянуться до .failure. Компилятору этого достаточно, а вот тесту такое сужение обходится дорого.
if это ветвление, а ветвление может не выполниться. Пока первый expect на месте, всё честно: outcome оказался успехом, проверка красная. Но стоит кому-нибудь ослабить первую строку (заменить на expect(outcome).toBeDefined(), закомментировать на время отладки, потерять при мердже), и тело if тихо перестанет исполняться. Ни одна настоящая проверка не отработает, а тест останется зелёным. Получается тест, который физически не может упасть, при этом выглядит как полноценный.
Проблема ровно та же, что с if (user) { expect(...) } в обычных тестах, просто здесь её маскирует необходимость сужения типа. Значит, сужение надо унести из теста в матчер.
В fp-ts для Either такие матчеры давно живут отдельным пакетом: toBeRight() проверяет ветку, toEqualRight(value) проверяет ветку и значение разом. Для Effect-овского Result напишем свои: expect.extend из vitest занимает полстраницы.
// test/result-matchers.ts
import { Result } from 'effect';
import { expect } from 'vitest';
/** Конструктор класса ошибки: `NetworkError`, `ConfigParseError` и подобные. */
type ErrorClass = abstract new (...args: never[]) => object;
declare module 'vitest' {
interface Matchers<T = any> {
toBeSuccess: () => T;
toEqualSuccess: (expected: unknown) => T;
toBeFailure: (errorClass?: ErrorClass) => T;
toEqualFailure: (expected: unknown) => T;
}
}
const show = (value: unknown): string => {
if (value instanceof Error) {
return `${value.name}: ${value.message}`;
}
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
};
const describeResult = (result: Result.Result<unknown, unknown>): string =>
Result.isSuccess(result) ? `Success(${show(result.success)})` : `Failure(${show(result.failure)})`;
/** Матчеры принимают только Result: всё остальное это опечатка в тесте, а не провал проверки. */
const asResult = (received: unknown, matcher: string): Result.Result<unknown, unknown> => {
if (!Result.isResult(received)) {
throw new TypeError(`${matcher} ждёт Result, получил ${show(received)}`);
}
return received;
};
expect.extend({
toBeSuccess(received: unknown) {
const result = asResult(received, 'toBeSuccess');
return {
pass: Result.isSuccess(result),
message: () =>
Result.isSuccess(result)
? `Ожидался не Success, получен ${describeResult(result)}`
: `Ожидался Success, получен ${describeResult(result)}`,
};
},
toEqualSuccess(received: unknown, expected: unknown) {
const result = asResult(received, 'toEqualSuccess');
if (Result.isFailure(result)) {
return { pass: false, message: () => `Ожидался Success, получен ${describeResult(result)}` };
}
const pass = this.equals(result.success, expected);
return {
pass,
actual: result.success,
expected,
message: () =>
pass ? 'Success содержит значение, которого не ждали' : 'Success содержит другое значение',
};
},
toBeFailure(received: unknown, errorClass?: ErrorClass) {
const result = asResult(received, 'toBeFailure');
if (Result.isSuccess(result)) {
return { pass: false, message: () => `Ожидался Failure, получен ${describeResult(result)}` };
}
if (errorClass === undefined) {
return { pass: true, message: () => `Ожидался не Failure, получен ${describeResult(result)}` };
}
const pass = result.failure instanceof errorClass;
return {
pass,
message: () =>
pass
? `Ошибка не должна быть ${errorClass.name}, но она ${errorClass.name}`
: `Ожидалась ошибка ${errorClass.name}, получена ${show(result.failure)}`,
};
},
toEqualFailure(received: unknown, expected: unknown) {
const result = asResult(received, 'toEqualFailure');
if (Result.isSuccess(result)) {
return { pass: false, message: () => `Ожидался Failure, получен ${describeResult(result)}` };
}
const pass = this.equals(result.failure, expected);
return {
pass,
actual: result.failure,
expected,
message: () =>
pass ? 'Failure содержит ошибку, которой не ждали' : 'Failure содержит другую ошибку',
};
},
});
/** Проверяет, что Result это Success, и возвращает значение с сузившимся типом. */
export const expectSuccess = <A, E>(result: Result.Result<A, E>): A => {
if (Result.isFailure(result)) {
throw new Error(`Ожидался Success, получен Failure(${show(result.failure)})`);
}
return result.success;
};
/** Проверяет, что Result это Failure, и возвращает ошибку с сузившимся типом. */
export const expectFailure = <A, E>(result: Result.Result<A, E>): E => {
if (Result.isSuccess(result)) {
throw new Error(`Ожидался Failure, получен Success(${show(result.success)})`);
}
return result.failure;
};
Разбор по частям.
declare module 'vitest' дописывает четыре метода в интерфейс Matchers, и после этого expect(result).toBeFailure(NetworkError) знает про себя TypeScript. Параметр обязан называться так же и иметь тот же дефолт, что в исходном объявлении vitest, иначе слияние деклараций не сойдётся.
Каждый матчер возвращает { pass, message }. pass это результат проверки, а message вызывается только когда тест падает, и должен объяснять провал с точки зрения того, что мы ждали. Vitest сам разворачивает message наоборот для .not, поэтому текст пишем для обеих веток: сравни две строки в toBeSuccess.
this.equals это та же глубокая сверка, на которой стоит toEqual. Вручную её писать не надо, она приходит в контексте матчера. Заодно возвращаем actual и expected: по ним vitest рисует diff.
asResult бросает TypeError, если в матчер прилетел не Result. Это не провал проверки, а опечатка в тесте, и вести себя такая ситуация должна как поломка, а не как красный expect.
Пара expectSuccess / expectFailure внизу файла нужна, когда проверок по полям много и городить toEqualFailure с целиком собранной ошибкой неудобно. Это те же сужающие функции, только они не ветвятся, а бросают. Тест на них упадёт всегда, когда ветка не та.
Матчеры подключаются один раз в конфиге, дальше они доступны во всех тестах:
// vitest.config.ts
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
// Матчеры toBeSuccess / toBeFailure для Result, см. test/result-matchers.ts
setupFiles: ['./test/result-matchers.ts'],
},
});
Теперь тот же тест на probe без единого if:
import { describe, it } from '@effect/vitest';
import { Effect } from 'effect';
import { expect } from 'vitest';
import { NetworkError } from '~/errors.ts';
import { probe } from '~/probe.ts';
import { expectFailure } from './result-matchers.ts';
describe('probe', () => {
it.effect('возвращает NetworkError при сетевой ошибке', () =>
Effect.gen(function* () {
const outcome = yield* Effect.result(probe('https://does-not-exist.local'));
expect(outcome).toBeFailure(NetworkError);
expect(expectFailure(outcome).url).toBe('https://does-not-exist.local');
}),
);
});
Четыре строки превратились в две, if исчез, а падение стало разговорчивым: вместо expected false to be true в отчёте будет Ожидался Failure, получен Success({"status":200,...}).
Что чем проверять:
| Матчер | Проверяет | Когда брать |
|---|---|---|
toBeSuccess() | ветка Success | значение не важно, важен сам факт |
toEqualSuccess(value) | ветка Success и значение | самый частый случай для успеха |
toBeFailure() | ветка Failure | ошибка любая, лишь бы упало |
toBeFailure(ErrorClass) | ветка Failure и класс ошибки | самый частый случай для ошибки |
toEqualFailure(error) | ветка Failure и сама ошибка | ошибка собирается целиком, все поля важны |
expectFailure(result) | ветка Failure, отдаёт ошибку | дальше проверяем отдельные поля |
expectSuccess(result) | ветка Success, отдаёт значение | дальше проверяем отдельные поля |
Тот же приём один в один переносится на Exit и на Option: toBeExitFailure, toBeSome, toEqualSome пишутся по этому же шаблону. Начинать стоит с Result, потому что именно он возвращается из Effect.result и встречается в тестах чаще всех.
Что взять с собой
import { describe, it } from '@effect/vitest', аexpectиз гологоvitest.it.effectдля подавляющего большинства тестов,it.liveкогда нужны реальные часы и окружение. Отдельногоit.scopedнет,it.effectуже скоупный.TestClockподключается явным Layer-ом, автоматически он больше не подкладывается.expect(...)работает прямо внутриEffect.gen, без обёрток.- Ожидаемые ошибки проверяй через
Effect.result, неожиданные (die,interrupt) черезEffect.exit. Resultпроверяй матчерами (toBeFailure(NetworkError),toEqualSuccess(value)), а не паройexpectплюсif.ifв тесте это ветка, которая может не выполниться, и тогда тест зелёный и пустой.
Раздел 2 · TestClock · виртуальные часы
Сцена
В Pulse расписание это сердце системы: каждые 30 секунд пробинг, через три провала переключение, при таймауте 5 секунд retry с jitter. Если ты тестируешь “монитор делает 5 запусков за 2.5 минуты”, ты не хочешь сидеть в CI две с половиной минуты. Ты хочешь сказать “пускай TestClock считает, что прошла минута”, и убедиться, что файбер сделал ровно столько работы, сколько должен был.
Идея словами
Effect.sleep(d), Effect.timeout, Schedule.spaced, Stream.groupedWithin под капотом не зовут setTimeout. Они кладут файбер в “ждунов” текущего Clock-сервиса. По умолчанию Clock подключён к настоящим часам и setTimeout. Подключаешь Layer TestClock.layer(), и вместо них появляются виртуальные часы, у которых:
- виртуальное время начинается с 0;
TestClock.adjust(d)сдвигает виртуальное время наd, пробуждая всех файберов, чейsleepтеперь истёк;TestClock.setTime(epochMs)ставит абсолютное время;Clock.currentTimeMillis(обычный, из ядра) отдаёт текущее виртуальное время;TestClock.withLive(effect)выполняет кусок теста на настоящих часах, не снимая подмену со всего остального.
Модуль живёт в подмодуле effect/testing, вместе с TestConsole, TestSchema и FastCheck. Отдельного TestContext, который раньше тащил все тестовые сервисы разом, больше нет: каждый подключается своим Layer-ом.
Шаг 1 · Effect.sleep под TestClock
import { describe, it } from '@effect/vitest';
import { Effect, Fiber, Ref } from 'effect';
import { TestClock } from 'effect/testing';
import { expect } from 'vitest';
describe('TestClock', () => {
it.effect('sleep подвешивает fiber до adjust', () =>
Effect.gen(function* () {
const log = yield* Ref.make<ReadonlyArray<string>>([]);
const fiber = yield* Effect.forkChild(
Effect.gen(function* () {
yield* Ref.update(log, (xs) => [...xs, 'before']);
yield* Effect.sleep('1 minute');
yield* Ref.update(log, (xs) => [...xs, 'after']);
}),
);
yield* TestClock.adjust('59 seconds');
expect(yield* Ref.get(log)).toEqual(['before']);
yield* TestClock.adjust('1 second');
yield* Fiber.await(fiber);
expect(yield* Ref.get(log)).toEqual(['before', 'after']);
}).pipe(Effect.provide(TestClock.layer())),
);
});
Запускается за 0мс реального времени. Виртуальное время сдвинулось на минуту, файбер увидел “минута прошла” и продолжил.
Три вещи в этом листинге, которые в старом коде выглядели иначе. Effect.fork теперь Effect.forkChild: имя честно говорит, что файбер привязан к родительскому и умрёт вместе с ним. Fiber перестал быть yield-абельным сам по себе, дожидаемся через Fiber.await(fiber). И TestClock.layer() подаётся явно.
Шаг 2 · Schedule.spaced под TestClock
Расписание Pulse: “проверять каждые 30 секунд”. Хотим убедиться, что за 2.5 минуты будет ровно 6 запусков, на отметках 0с, 30с, 60с, 90с, 120с, 150с.
import { describe, it } from '@effect/vitest';
import { Effect, Fiber, Ref, Schedule } from 'effect';
import { TestClock } from 'effect/testing';
import { expect } from 'vitest';
describe('probe schedule', () => {
it.effect('5 запусков за 2.5 минуты при интервале 30s', () =>
Effect.gen(function* () {
const calls = yield* Ref.make(0);
const probeStub = Ref.update(calls, (n) => n + 1);
const scheduled = probeStub.pipe(
Effect.repeat(Schedule.spaced('30 seconds')),
);
const fiber = yield* Effect.forkChild(scheduled);
// 0 секунд: первый запуск произошёл сразу, до spaced
expect(yield* Ref.get(calls)).toBe(1);
// 30 секунд: запуск №2
yield* TestClock.adjust('30 seconds');
expect(yield* Ref.get(calls)).toBe(2);
// ещё минута: запуски №3 и №4
yield* TestClock.adjust('1 minute');
expect(yield* Ref.get(calls)).toBe(4);
// ещё минута: запуск №5 (на 2:00) и №6 (на 2:30)
yield* TestClock.adjust('1 minute');
expect(yield* Ref.get(calls)).toBe(6);
yield* Fiber.interrupt(fiber);
}).pipe(Effect.provide(TestClock.layer())),
);
});
Что важно прочитать вслух:
Effect.repeat(Schedule.spaced('30 seconds'))выполняет тело сразу, потом ждёт 30 секунд и выполняет ещё раз, и так далее. Поэтому в 0 секунд счётчик уже на 1.TestClock.adjust('30 seconds')пробудил один уснувший файбер. Если бы мы сдвинули на 70 секунд, пробудили бы два (30 и 60).Fiber.interrupt(fiber)в конце это хорошая привычка: он останавливает бесконечное расписание сразу, а не оставляет его дожидаться закрытия скоупа теста.
Шаг 3 · Effect.timeout под TestClock
Effect.timeout(d) оборачивает программу в “если за d не вернулась, упасть с TimeoutError”. Под TestClock срабатывает мгновенно:
import { describe, it } from '@effect/vitest';
import { Cause, Effect, Exit, Fiber } from 'effect';
import { TestClock } from 'effect/testing';
import { expect } from 'vitest';
describe('Effect.timeout', () => {
it.effect('падает TimeoutError, если не успел', () =>
Effect.gen(function* () {
const slow = Effect.sleep('10 seconds').pipe(Effect.as('ok'));
const fiber = yield* Effect.forkChild(slow.pipe(Effect.timeout('5 seconds')));
yield* TestClock.adjust('4 seconds');
// ещё не упало
yield* TestClock.adjust('1 second');
const exit = yield* Fiber.await(fiber);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(Cause.hasFails(exit.cause)).toBe(true);
expect(
exit.cause.reasons.some((r) => Cause.isFailReason(r) && Cause.isTimeoutError(r.error)),
).toBe(true);
}
}).pipe(Effect.provide(TestClock.layer())),
);
it.effect('успел, возвращает значение', () =>
Effect.gen(function* () {
const fast = Effect.sleep('1 second').pipe(Effect.as('ok'));
const fiber = yield* Effect.forkChild(fast.pipe(Effect.timeout('5 seconds')));
yield* TestClock.adjust('2 seconds');
const result = yield* Fiber.join(fiber);
expect(result).toBe('ok');
}).pipe(Effect.provide(TestClock.layer())),
);
});
Тут видно, как устроен плоский Cause: у него есть массив reasons, и по нему можно просто пройтись. Раньше добраться до конкретной ошибки было сложнее, через Cause.failureOption и разбор Option.
Заодно обрати внимание на имя ошибки. Встроенный таймаут падает TimeoutError (проверяется через Cause.isTimeoutError), а не TimeoutException. В v4 суффикс Exception вычищен из всей библиотеки: ошибка это ошибка, отдельного слова для неё не нужно.
Это и есть точечный тест на любую timeout-логику Pulse: race с резервным URL, отмена медленного probe, ограничение на Storage.readAll.
Шаг 4 · withLive для кусочка реального времени
Иногда посреди виртуального теста нужен настоящий тик: например, дать сторонней библиотеке, которая живёт на честном setTimeout, доделать свою работу. Снимать подмену со всего теста ради этого не надо, есть точечный вариант:
yield* TestClock.testClockWith((clock) => clock.withLive(Effect.sleep('10 millis')));
Внутри withLive время идёт по-настоящему, снаружи остаётся виртуальным.
Если же тест “не доходит туда, куда должен”, первое подозрение всегда одно: файбер висит на sleep длиннее, чем ты сдвинул часы. Лечится не отладочным API, а дисциплиной: сдвигай ровно на ту величину, которая стоит в коде, и проверяй счётчик после каждого сдвига, как в примере из Шага 2.
Шаг 5 · комбинация с Stream.groupedWithin
В уроке 09 · Stream и Sink мы собрали JsonlWriter через Stream.groupedWithin(64, '1 second'). Тест на batching, который мы там черновиком набросали, полностью держится на TestClock:
import { describe, it } from '@effect/vitest';
import { Effect, Fiber, Ref, Stream } from 'effect';
import { TestClock } from 'effect/testing';
import { expect } from 'vitest';
describe('JsonlWriter batching', () => {
it.effect('flush по таймеру, даже если буфер не заполнен', () =>
Effect.gen(function* () {
const collected = yield* Ref.make<ReadonlyArray<number>>([]);
const source = Stream.make(1, 2, 3); // 3 < 64
const program = source.pipe(
Stream.groupedWithin(64, '1 second'),
Stream.tap((batch) => Ref.update(collected, (xs) => [...xs, ...batch])),
Stream.runDrain,
);
const fiber = yield* Effect.forkChild(program);
yield* TestClock.adjust('999 millis');
expect(yield* Ref.get(collected)).toEqual([]); // ещё не flush-ило
yield* TestClock.adjust('1 millis');
yield* Fiber.await(fiber);
expect(yield* Ref.get(collected)).toEqual([1, 2, 3]);
}).pipe(Effect.provide(TestClock.layer())),
);
});
Один и тот же приём решает оба сценария (“buffer переполнен” и “вышел таймер”). Никаких await sleep(1000) в тестах. Батч тут приходит обычным массивом, поэтому разворачивается простым spread, без Chunk.toReadonlyArray (09 · Stream).
Что взять с собой
TestClockподложен подit.effectавтоматически.Clock.currentTimeMillisотдаёт виртуальное время.TestClock.adjust(d)сдвигает время вперёд и будит все файберы, у которыхsleepистёк.Schedule.spaced(d).pipe(Effect.repeat)выполняет тело сразу, потом каждыеd. На 0 секунд счётчик уже на 1.Effect.timeoutтестируется ровно тем же сдвигом: за секунду до дедлайна, на дедлайне, за секунду после.Stream.groupedWithin(n, d)тестируется паройadjust('999 millis')иadjust('1 millis'), переход через границу окна.
Раздел 3 · Тестовый Layer без vi.mock
Сцена
В 04 · Сервисы и Layer мы подсмотрели идею “подмена через Layer вместо vi.mock”. Теперь это превращается в дисциплину. У Pulse девять сервисов в MainLive, и для разных тестов нам нужны разные комбинации заглушек: один тест хочет настоящий Storage, но заглушку HttpClient; другой хочет настоящие MonitorEventsPubSub (PubSub это in-memory, гонять можно), но заглушку Logger.
Лекарство: MainTest это не один Layer, а функция, которая собирает Layer под конкретный тест.
Идея словами
import { Layer } from 'effect';
export const MainTest = (opts: {
http: Layer.Layer<HttpClient>;
storage?: Layer.Layer<Storage>;
logger?: Layer.Layer<Logger>;
}) =>
Layer.mergeAll(
opts.http,
opts.storage ?? StorageInMemoryLive,
opts.logger ?? LoggerSilentLive,
SlaLive,
MonitorEventsPubSubLive(),
EventsFeedLive,
JsonlWriterLive,
DnsCacheLive,
);
Так писать удобнее, чем “пять разных MainTestVariant1, MainTestVariant2”. В тестах:
it.effect('успешный пробинг пишет ProbeSuccess', () =>
Effect.gen(function* () {
// ...
}).pipe(
Effect.provide(
MainTest({
http: HttpStub({ 'https://github.com': { status: 200, body: '' } }),
}),
),
),
);
HttpStub это фабрика Layer-а, которая принимает фикстурный ответ и отдаёт замокированный HttpClient. Реализуем в Шаге 2.
Шаг 1 · StorageInMemoryLive
// pulse-<nick>/src/test/storage-in-memory.ts
import { Effect, Layer, Ref } from 'effect';
import type { MonitorEvent } from '../events.ts';
import { Storage } from '../services/storage.ts';
export const StorageInMemoryLive = Layer.effect(
Storage,
Effect.gen(function* () {
const events = yield* Ref.make<ReadonlyArray<MonitorEvent>>([]);
return Storage.of({
append: (event) => Ref.update(events, (xs) => [...xs, event]),
readAll: () => Ref.get(events),
});
}),
);
Storage.of(impl) это smart-конструктор, который Context.Service даёт автоматически. Он проверяет на типах, что в impl все нужные методы. Ref<ReadonlyArray<MonitorEvent>> живёт ровно столько, сколько живёт Layer.
Заодно обрати внимание на сам Layer.effect. Отдельного Layer.scoped в v4 нет: Layer.effect сам вычитает Scope из требований, поэтому и обычные конструкторы, и те, что открывают ресурс, пишутся одним и тем же вызовом (05 · Resources).
Шаг 2 · HttpStub с предзаписанными ответами
// pulse-<nick>/src/test/http-stub.ts
import { Effect, Layer } from 'effect';
import { NetworkError } from '../errors.ts';
import { HttpClient } from '../services/http-client.ts';
export type StubResponses = Record<string, { status: number; body: string }>;
export const HttpStub = (responses: StubResponses) =>
Layer.succeed(
HttpClient,
HttpClient.of({
get: (url) => {
const fake = responses[url];
if (fake === undefined) {
return Effect.fail(
new NetworkError({ url, cause: `stub: no response configured for ${url}` }),
);
}
return Effect.succeed(fake);
},
post: (url) =>
Effect.fail(new NetworkError({ url, cause: 'stub: POST not supported' })),
}),
);
HttpStub это функция от данных, возвращающая Layer. В тесте подаёшь словарь URL -> ответ, и заглушка стреляет тем, что просили.
Шаг 3 · LoggerSilentLive и LoggerCollectingLive
// pulse-<nick>/src/test/logger-silent.ts
import { Effect, Layer } from 'effect';
import { Logger } from '../services/logger.ts';
export const LoggerSilentLive = Layer.succeed(
Logger,
Logger.of({
info: () => Effect.void,
warn: () => Effect.void,
error: () => Effect.void,
}),
);
Этот Layer глушит весь лог, тест не плюётся в stdout. Если хочется проверить что именно залогировали, бери собирающий вариант:
import { Effect, Layer, Ref } from 'effect';
import { Logger } from '../services/logger.ts';
export const LoggerCollectingLive = Effect.gen(function* () {
const lines = yield* Ref.make<ReadonlyArray<{ level: string; msg: string }>>([]);
const layer = Layer.succeed(
Logger,
Logger.of({
info: (msg) => Ref.update(lines, (xs) => [...xs, { level: 'info', msg }]),
warn: (msg) => Ref.update(lines, (xs) => [...xs, { level: 'warn', msg }]),
error: (msg) => Ref.update(lines, (xs) => [...xs, { level: 'error', msg }]),
}),
);
return { layer, lines } as const;
});
Внутри теста:
const { layer, lines } = yield* LoggerCollectingLive;
yield* program.pipe(Effect.provide(layer));
expect(yield* Ref.get(lines)).toContainEqual({ level: 'warn', msg: 'switched to fallback' });
Шаг 4 · MainTest целиком
// pulse-<nick>/src/test/main-test.ts
import { Layer } from 'effect';
import { DnsCacheLive } from '../batching/dns-cache.ts';
import { MonitorEventsPubSubLive } from '../concurrency/coordination.ts';
import { SlaLive } from '../concurrency/sla-state.ts';
import { EventsFeedLive } from '../stream/events-feed.ts';
import { JsonlWriterLive } from '../stream/jsonl-writer.ts';
import type { Storage } from '../services/storage.ts';
import type { HttpClient } from '../services/http-client.ts';
import type { Logger } from '../services/logger.ts';
import { HttpStub, type StubResponses } from './http-stub.ts';
import { LoggerSilentLive } from './logger-silent.ts';
import { StorageInMemoryLive } from './storage-in-memory.ts';
type MainTestOpts = {
readonly http?: Layer.Layer<HttpClient> | StubResponses;
readonly storage?: Layer.Layer<Storage>;
readonly logger?: Layer.Layer<Logger>;
};
export const MainTest = (opts: MainTestOpts = {}) => {
const httpLayer =
opts.http === undefined
? HttpStub({})
: 'get' in opts.http
? opts.http
: HttpStub(opts.http);
return Layer.mergeAll(
httpLayer,
opts.storage ?? StorageInMemoryLive,
opts.logger ?? LoggerSilentLive,
SlaLive,
MonitorEventsPubSubLive(),
EventsFeedLive,
JsonlWriterLive,
DnsCacheLive,
);
};
Обрати внимание на конвенцию имён. Слой называется явно (SlaLive, DnsCacheLive) и импортируется отдельно от тега. Раньше слой генерировался автоматически и звался Sla.Default; теперь тег и его реализация разведены, и это удобнее: у одного тега может быть сколько угодно слоёв с говорящими именами (StorageLive, StorageInMemoryLive), и ни один из них не претендует на звание “того самого по умолчанию”.
Эта функция собирает рабочую копию MainLive с заглушками вместо реальной сети, реальных файлов и реального вывода. Все Effect-овые сервисы (Sla, MonitorEventsPubSub, EventsFeed, JsonlWriter, DnsCache) идут реальные: их реализация in-memory и детерминированная, мокать их не за чем. Тест видит ровно тот же тип Effect<A, E, HttpClient | Storage | Logger | Sla | MonitorEventsPubSub | EventsFeed | JsonlWriter | DnsCache>, что и продовая программа, и тот же набор Layer-ов через Effect.provide.
Шаг 5 · полный тест на probe плюс recordResult
// pulse-<nick>/test/probe.test.ts
import { describe, it } from '@effect/vitest';
import { Effect, Schema } from 'effect';
import { expect } from 'vitest';
import { MonitorId } from '~/config.ts';
import { probe } from '~/probe.ts';
import { recordResult } from '~/matching.ts';
import { MainTest } from '~/test/main-test.ts';
const id = Schema.decodeUnknownSync(MonitorId)('github');
describe('probe', () => {
it.effect('ProbeSuccess на 200', () =>
Effect.gen(function* () {
const event = yield* recordResult(id, probe('https://github.com'));
expect(event._tag).toBe('ProbeSuccess');
if (event._tag === 'ProbeSuccess') {
expect(event.status).toBe(200);
}
}).pipe(
Effect.provide(
MainTest({ http: { 'https://github.com': { status: 200, body: '' } } }),
),
),
);
it.effect('ProbeFailure с reason="http-status" на 503', () =>
Effect.gen(function* () {
const event = yield* recordResult(id, probe('https://github.com'));
expect(event._tag).toBe('ProbeFailure');
if (event._tag === 'ProbeFailure') {
expect(event.reason).toBe('http-status');
}
}).pipe(
Effect.provide(
MainTest({ http: { 'https://github.com': { status: 503, body: '' } } }),
),
),
);
});
Никакого vi.mock. Подмена идёт через Layer, типы проверены, лишних запросов в сеть нет. Это тот же приём, что мы видели в 04 · Сервисы и Layer, только теперь он собран в готовую тестовую инфраструктуру.
Что взять с собой
MainTestэто функция от опций, не один глобальный Layer. Так удобнее подменять кусочки.Layer.succeed(Tag, Tag.of(impl))базовая форма заглушки,Tag.ofдаётContext.Service.LoggerSilentLiveчтобы тест не плевался,LoggerCollectingLiveчтобы проверять, что именно сказал.- Реальные in-memory сервисы (
Sla,MonitorEventsPubSub,EventsFeed) мокать не нужно. Их детерминированности уже хватает.
Раздел 4 · it.layer · общий рантайм на блок
Сцена
Иногда тест дорогой: подъём Storage с реальным файлом, прогрев DNS-резолвера, запуск фонового файбера через Effect.forkScoped. Делать это для каждого it.effect накладно. @effect/vitest даёт it.layer(...), который собирает Layer один раз на блок describe и переиспользует между тестами.
Идея словами
import { describe, it } from '@effect/vitest';
import { Layer } from 'effect';
import { MainTest } from '~/test/main-test.ts';
describe.concurrent('Pulse end-to-end', () => {
const TestLayer = MainTest({ http: { /* ... */ } });
it.layer(TestLayer, { timeout: '10 seconds' })('успешный пробинг', ({ effect }) =>
effect(Effect.gen(function* () {
// ...
})),
);
it.layer(TestLayer)('второй тест на том же графе', ({ effect }) =>
effect(Effect.gen(function* () {
// ...
})),
);
});
it.layer(layer)(name, run) принимает Layer и колбэк. Колбэк получает объект с методом effect, через который ты запускаешь свою программу. Внутри блока Layer строится один раз, и сервисы (Sla, MonitorEventsPubSub, и так далее) разделяются между тестами.
Шаг 1 · когда брать it.layer, когда it.effect
Правило:
it.effect, каждый тест получает свой свежий граф сервисов. Изоляция максимальная, прогревов больше.it.layer, все тесты блока делят один граф. Изоляция меньше, скорость выше.
Если в Storage копится состояние между тестами и сбросить его нечем, нужен it.effect со свежим Layer-ом. Если состояние не накапливается (тесты на чистый probe против HttpStub-а), бери it.layer. Если состояние копится, но его можно очистить, оставайся на it.layer и подключай beforeEach.effect (Шаг 2).
Шаг 2 · beforeEach для сброса состояния
С it.layer ты можешь почистить накопленное состояние перед каждым тестом через beforeEach:
import { describe, it, beforeEach } from '@effect/vitest';
import { Effect, Ref } from 'effect';
import { Storage } from '~/services/storage.ts';
describe('storage shared', () => {
const TestLayer = MainTest({});
beforeEach.effect(() =>
Effect.gen(function* () {
const storage = yield* Storage;
// если у InMemory-Storage есть метод reset, вызови его
yield* storage.reset?.();
}).pipe(Effect.provide(TestLayer)),
);
it.layer(TestLayer)('первый тест', ({ effect }) =>
effect(Effect.gen(function* () {
// storage пустой
})),
);
});
В @effect/vitest хуки beforeEach.effect, afterEach.effect, beforeAll.effect, afterAll.effect принимают Effect-программу и сами её запускают.
Что взять с собой
it.layer(layer)для блоков, где Layer тяжёлый и тестов много.it.effectпо умолчанию, если изоляция важнее скорости.beforeEach.effect,afterEach.effectэто Effect-варианты обычных хуковvitest.
Раздел 5 · Property-тесты и стримы
Разделы 1-4 закрывают “как запустить тест”. Осталось два вида проверок, которые примерами писать больно.
Шаг 1 · Генератор берётся из схемы
Схема уже описывает, какие значения допустимы. Значит, из неё можно сгенерировать валидные значения, и не выдумывать примеры руками:
Самая ценная проверка на схемах это обратимость: закодировали, раскодировали, получили исходное. Под неё есть готовый набор ассертов, TestSchema.Asserts:
import { TestSchema } from 'effect/testing';
const asserts = new TestSchema.Asserts(Monitor);
it('encode с последующим decode возвращает исходное значение', () =>
asserts.verifyLosslessTransformation({ params: { seed: 1, numRuns: 50 } }));
it('генератор выдаёт значения, проходящие схему', () => {
asserts.arbitrary().verifyGeneration({ params: { seed: 1, numRuns: 50 } });
});
verifyLosslessTransformation() сам генерирует значения по схеме, гоняет их через кодирование и раскодирование и сверяет результат с исходным. Имя длинное, зато точно описывает проверяемое свойство: преобразование не теряет информацию. Отдельного модуля Arbitrary с ручным Arbitrary.make(Schema) в v4 нет: генератор это внутренняя деталь схемы, и наружу торчат готовые проверки. FastCheck переехал в effect/testing/FastCheck и по-прежнему идёт в комплекте, отдельная зависимость не нужна.
Если нужен свой предикат, а не round-trip, FastCheck берётся напрямую:
import { FastCheck } from 'effect/testing';
FastCheck.assert(
FastCheck.property(FastCheck.integer({ min: 1000, max: 600_000 }), (ms) =>
Schema.is(IntervalMs)(ms),
),
);
Такие тесты ловят то, что примерами почти не поймаешь: потерю точности у чисел, схлопывание пустой строки, поле с умолчанием, которое затирает явно переданное значение. Десяток строк, а покрытие шире сорока ручных примеров.
Когда падает, чини схему, а не тест: генератор нашёл вход, на котором контракт не выполняется, и это находка, а не помеха.
Подробнее про Arbitrary и границы применимости в 21 · Schema, второй заход, а теория property-based тестирования в 29-testing · 15.
Шаг 2 · Стрим тестируется как эффект
Stream это описание, поэтому тест на него это обычный it.effect: собрать вход, прогнать, сравнить.
it.effect('битая строка не роняет поток', () =>
Effect.gen(function* () {
const items = yield* parseStream(Stream.make('{"id":1}\nмусор\n{"id":2}')).pipe(
Stream.runCollect,
);
expect(items.length).toBe(2);
}),
);
Stream.runCollect отдаёт обычный массив, поэтому длина проверяется через items.length, безо всяких обёрток над Chunk.
Три приёма, которые закрывают почти все тесты на стримы.
Источник задаётся Stream.make или Stream.fromIterable. Никаких моков и реальных сокетов: вход это данные.
Границы чанков задаются явно. Реальный поток приходит кусками, разорванными в произвольных местах, и именно на этом ломаются сборщики буферов. Stream.make('{"id":1}\n{"id":', '2}\n') воспроизводит разрыв, а Stream.rechunk(1) заставляет обрабатывать по одному элементу.
Время идёт по TestClock. Всё, что связано с окнами и паузами (groupedWithin, debounce, throttle, aggregateWithin), тестируется мгновенно: форкаешь прогон, двигаешь часы через TestClock.adjust, проверяешь, что пачка вышла.
Отдельно про завершение: если тест на бесконечном источнике зависает, добавь Stream.take(n) или Stream.interruptWhen. Забытое условие остановки в тесте это первый признак того, что и в проде его нет.
Раздел 6 · Code style · dual API
Сцена
В Effect есть два стиля вызова: data-first и pipe-friendly. Это:
// data-first
Effect.map(probe('https://x'), (r) => r.status);
// pipe-friendly
probe('https://x').pipe(Effect.map((r) => r.status));
Оба читаются. На длинной цепочке pipe-friendly выгоднее (читается сверху вниз). На одном вызове data-first короче. Один и тот же Effect.map поддерживает оба. Сделано это через dual.
Идея словами
import { dual } from 'effect/Function';
export const map: {
<A, B>(self: Effect.Effect<A>, f: (a: A) => B): Effect.Effect<B>;
<A, B>(f: (a: A) => B): (self: Effect.Effect<A>) => Effect.Effect<B>;
} = dual(2, <A, B>(self: Effect.Effect<A>, f: (a: A) => B): Effect.Effect<B> =>
/* реализация data-first */,
);
dual(arity, dataFirstImpl) принимает:
arity, ожидаемое число аргументов data-first-варианта. Если вызвали с этим числом, идёт data-first. Если меньше, считается pipe-friendly, иdualкурирует.dataFirstImpl, реализация data-first (она и так есть).
На выходе функция с двумя сигнатурами, обе работают.
Шаг 1 · реализуем tapLog для Pulse
Допустим, мы хотим хелпер: “выполни программу, при успехе залогируй короткое сообщение”. Хочется писать оба варианта:
tapLog(probe(url), 'probe ok'); // data-first
probe(url).pipe(tapLog('probe ok')); // pipe-friendly
Реализация через dual:
// pulse-<nick>/src/lib/tap-log.ts
import { Console, Effect } from 'effect';
import { dual } from 'effect/Function';
export const tapLog: {
<A, E, R>(self: Effect.Effect<A, E, R>, msg: string): Effect.Effect<A, E, R>;
(msg: string): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
} = dual(
2,
<A, E, R>(self: Effect.Effect<A, E, R>, msg: string): Effect.Effect<A, E, R> =>
self.pipe(Effect.tap(() => Console.log(msg))),
);
Что прочитать:
- Сначала пишешь тип-сигнатуру обеих форм. Это важно: без неё TS не сможет вывести, какой вариант ты вызвал.
- Дальше
dual(2, impl). Число2, это сколько аргументов берёт data-first вариант. implреализует data-first. Pipe-friendly курируется автоматически.
Шаг 2 · dual с предикатом для сложных кейсов
Когда arity не помогает (например, последний аргумент опциональный), второй параметр у dual это предикат на arguments:
export const retryWith: {
<A, E, R, Out>(
self: Effect.Effect<A, E, R>,
schedule: Schedule.Schedule<Out, E>,
options?: { readonly while?: (e: E) => boolean },
): Effect.Effect<A, E, R>;
<A, E, Out>(
schedule: Schedule.Schedule<Out, E>,
options?: { readonly while?: (e: E) => boolean },
): <R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
} = dual(
(args) => Effect.isEffect(args[0]), // если первый аргумент это Effect, data-first
(self, schedule, options) =>
self.pipe(
Effect.retry({
schedule,
while: options?.while,
}),
),
);
(args) => boolean, предикат, который смотрит на массив arguments и говорит “это data-first вызов или нет”. Удобно, когда arity неоднозначна.
Шаг 3 · когда не писать dual
Не каждой функции нужна dual-обвязка. Правила:
- Если функцию вызывают только из
pipe, не делай data-first вариант. Просто экспортируй curried. - Если функция конструктор (
Schedule.spaced('30 seconds')),dualне нужна, она не оборачивает существующее значение. - Если функция уже в стандартной библиотеке Effect (
Effect.map,Effect.tap,Stream.map), она уже dual. Не оборачивай.
dual ценна для публичного API твоей библиотеки или модуля, где не знаешь, как именно вызывающий захочет писать.
Шаг 4 · отрицательное правило · не зови pipe в data-first
Один частый промах: внутри реализации dual забывают, что self.pipe(...) это self сам уже Effect. И пишут Effect.pipe(self, ...), который не существует. Правильно:
// ✓
self.pipe(Effect.map(f))
// ✗ нет такого
Effect.pipe(self, Effect.map(f))
Если хочется data-first внутри реализации, бери Effect.map(self, f) напрямую.
Что взять с собой
dual(arity, impl)для публичного API, который хочется звать в обоих стилях.- Тип-сигнатура обеих форм, первым делом. Без неё TS не выведет.
dualс предикатом, когда arity неоднозначна.- В стандартной библиотеке Effect почти всё уже dual. Не оборачивай повторно.
Раздел 7 · Code style · Branded углубление
Сцена
В 02 · Schema мы делали branded через Schema.brand. Schema нужна, когда значение приходит извне и его надо парсить. А что, если значение появилось внутри программы и парсить нечего? Например, RequestId генерится из crypto.randomUUID(), и нужен типовой барьер “это не любая строка”.
Для таких случаев в Effect есть отдельный модуль Brand, без Schema. Это и легче, и быстрее, потому что нет рантайм-валидации, если она и не нужна.
Идея словами
import { Brand } from 'effect';
type RequestId = string & Brand.Brand<'RequestId'>;
const RequestId = Brand.nominal<RequestId>();
const id = RequestId(crypto.randomUUID());
// id: RequestId
Brand.nominal<T>() это unsafe-конструктор: на рантайме это identity-функция, ничего не проверяет. На типах строка перестаёт быть взаимозаменяемой с обычным string.
Если проверка нужна, используй Brand.refined:
type Email = string & Brand.Brand<'Email'>;
const Email = Brand.refined<Email>(
(s) => s.includes('@'),
(s) => Brand.error(`expected email, got ${JSON.stringify(s)}`),
);
const valid = Email('a@b.c');
// valid: Email
const broken = Email('not-an-email');
// бросит на рантайме: BrandError("expected email, got "not-an-email"")
Brand.refined(predicate, onFail) принимает предикат и фабрику ошибки. На рантайме проверка работает синхронно, бросает Brand.BrandError. На типах ровно та же T & Brand<'Name'>.
Шаг 1 · Brand против Schema.brand
| Признак | Brand.nominal / Brand.refined | Schema.brand |
|---|---|---|
| откуда значение | изнутри программы | снаружи (JSON, env) |
| рантайм-проверка | нет/есть (refined) | всегда |
| стоимость на рантайме | 0 (nominal) / 1 предикат | полная парсинг-инфраструктура |
| интеграция с decode/encode | нет | да |
| вес в бандле | маленький | тяжелее (тащит effect/Schema) |
Правило: берёшь Schema, когда парсишь. Берёшь Brand, когда уже распарсил.
Шаг 2 · Brand для безопасных операций
Типичный сценарий: внутри программы есть EventId, нужно гарантировать, что два разных события не получат один ID, и сравнение происходит по типу.
import { Brand, Effect, Random } from 'effect';
type EventId = string & Brand.Brand<'EventId'>;
const EventId = Brand.nominal<EventId>();
const makeEventId = Effect.gen(function* () {
const ts = Date.now();
const rand = yield* Random.nextIntBetween(0, 1_000_000);
return EventId(`${ts}-${rand.toString(36)}`);
});
// makeEventId: Effect<EventId, never, never>
makeEventId отдаёт Effect<EventId>. Любая функция, которая принимает EventId, не примет случайно string:
declare function recordEvent(id: EventId, payload: unknown): Effect.Effect<void>;
const stringy: string = 'manual';
recordEvent(stringy, {});
// ^^^^^^^ Type 'string' is not assignable to type 'EventId'
Шаг 3 · Brand.all для составных условий
Brand.refined принимает один предикат. Если их несколько, проще скомпоновать через Brand.all:
import { Brand } from 'effect';
type ShortName = string & Brand.Brand<'ShortName'>;
const isNonEmpty = (s: string) => s.length > 0;
const isShortEnough = (s: string) => s.length <= 50;
const ShortName = Brand.all(
Brand.refined<ShortName>(isNonEmpty, (s) => Brand.error(`empty string: ${s}`)),
Brand.refined<ShortName>(isShortEnough, (s) => Brand.error(`too long: ${s.length}`)),
);
Это композиция двух brand-ов с одним и тем же типом ShortName. На рантайме оба предиката проверяются последовательно, при первом провале возникает ошибка.
Шаг 4 · brand в Pulse
В Pulse уже есть MonitorId, Url, Interval через Schema.brand (см. 02 · Schema). Они правильно живут в Schema, потому что приходят из JSON-конфига. К ним добавляем EventId через чистый Brand (он генерится внутри):
// pulse-<nick>/src/events.ts (дополнение)
import { Brand, Effect, Random } from 'effect';
export type EventId = string & Brand.Brand<'EventId'>;
export const EventId = Brand.nominal<EventId>();
export const makeEventId = Effect.gen(function* () {
const ts = yield* Clock.currentTimeMillis;
const rand = yield* Random.nextIntBetween(0, 1_000_000);
return EventId(`${ts}-${rand.toString(36)}`);
});
Все события, которые мы кладём в Storage, теперь маркируются EventId-ом. Случайно засунуть туда обычную строку не получится. В тестах подмена Random через Effect.withRandom(Random.fixed([0.42])) делает makeEventId детерминированным.
Что взять с собой
Brand.nominal<T>()для unsafe-brand-а, когда инвариант обеспечен снаружи.Brand.refined<T>(predicate, onFail)когда нужен рантайм-чек.Schema.brandберёшь на границе с внешним миром,Brandберёшь внутри программы.Brand.all(...)для композиции нескольких предикатов.
Раздел 8 · Code style · Effect.Do
Сцена
Effect.gen(function* () { ... }) это рабочая лошадка. Линейный, читаемый, отлаживается. Но иногда хочется остаться в pipe-стиле: например, вся программа уже выстроена как одна pipe-цепочка, и вставлять туда Effect.gen это перепрыгнуть через два стиля.
Для таких ситуаций есть Effect.Do. Это короткая do-нотация на pipe, без генераторов.
Идея словами
import { Effect } from 'effect';
const program = Effect.Do.pipe(
Effect.bind('a', () => Effect.succeed(1)),
Effect.bind('b', ({ a }) => Effect.succeed(a + 1)),
Effect.let('c', ({ a, b }) => a + b),
Effect.map(({ a, b, c }) => ({ a, b, c })),
);
// program: Effect<{ a: number; b: number; c: number }>
Что происходит:
Effect.DoэтоEffect<{}>, пустой объект-аккумулятор.Effect.bind('key', f)запускаетf, кладёт результат под ключkeyв аккумулятор.Effect.let('key', f)то же, ноfсинхронная (для чистых вычислений). Удобно для производных значений.Effect.map(...)финальный шаг, превращаешь объект в нужное значение.
То же самое через Effect.gen:
const program = Effect.gen(function* () {
const a = yield* Effect.succeed(1);
const b = yield* Effect.succeed(a + 1);
const c = a + b;
return { a, b, c };
});
Семантика идентична. Различается только синтаксис.
Шаг 1 · когда брать Effect.Do
Правило большого пальца:
Effect.genкогда шагов больше четырёх или нужна логика (if,for,try/catch). Линейный код легче читать.Effect.Doкогда шагов 2-3 и весь окружающий код уже вpipe-стиле. Не нужно прыгать между генераторами и pipe.Effect.Doкогда хочется явно подсветить структуру “набор связываний даёт объект”. Иногда читается как декларация формы.
Шаг 2 · Effect.bindTo чтобы войти в Do из существующего значения
Бывает, у тебя уже есть Effect<X>, и хочется превратить его в “первое связывание” с именем:
const program = probe('https://x').pipe(
Effect.bindTo('result'),
Effect.bind('event', ({ result }) =>
Effect.succeed({ _tag: 'ProbeSuccess' as const, status: result.status }),
),
);
// program: Effect<{ result: ProbeResult; event: { _tag: 'ProbeSuccess'; status: number } }>
bindTo('result') оборачивает текущее значение в { result: ... } и переключает дальнейшую цепочку в do-режим.
Шаг 3 · Pulse · recordResult через Effect.Do
Вариант recordResult через Effect.Do (для сравнения с gen-вариантом из урока 03 · Tagged-ошибки):
import { Clock, Effect } from 'effect';
export const recordResultDo = <R>(
monitorId: MonitorId,
probeEffect: Effect.Effect<ProbeResult, NetworkError | HttpStatusError | TimeoutError, R>,
) =>
probeEffect.pipe(
Effect.matchEffect({
onSuccess: (result) =>
Effect.Do.pipe(
Effect.bind('at', () => Clock.currentTimeMillis),
Effect.map(
({ at }): ProbeSuccess => ({
_tag: 'ProbeSuccess',
monitorId,
url: result.url,
status: result.status,
elapsedMs: result.elapsedMs,
at,
}),
),
),
onFailure: (error) =>
Effect.Do.pipe(
Effect.bind('at', () => Clock.currentTimeMillis),
Effect.let('reason', () =>
error._tag === 'TimeoutError'
? ('timeout' as const)
: error._tag === 'HttpStatusError'
? ('http-status' as const)
: ('network' as const),
),
Effect.map(
({ at, reason }): ProbeFailure => ({
_tag: 'ProbeFailure',
monitorId,
url: error.url,
reason,
at,
}),
),
),
}),
);
Сравни с gen-вариантом из урока 03. Здесь Effect.Do чуть многословнее, но видно структуру “одно эффектное связывание (at), одно чистое (reason), потом сборка объекта”. В нашей кодовой базе мы оставляем Effect.gen, потому что для нас линейный код приятнее. Но решение это вкус, не догма.
Что взять с собой
Effect.Do.pipe(Effect.bind, Effect.let, ..., Effect.map)это do-нотация на pipe.Effect.bindдля эффектных шагов,Effect.letдля чистых.Effect.bindTo('key')чтобы войти в do-режим из существующего Effect.- Бери
genпо умолчанию,Doкогда окружение pipe-первое и шагов мало.
Раздел 9 · Code style · Data
Сцена
В TypeScript структурное равенство нативно работает только на примитивах: 1 === 1, 'a' === 'a'. Два разных объекта с одинаковыми полями === не равны. На обычном коде это редко больно, но в Effect-системе равенство по значению нужно постоянно: HashMap, HashSet, Effect.race на дедупликации, Stream с distinct. Везде, где нужно “одинаковый ли это объект”, библиотеке нужен механизм.
Effect даёт его через модуль Data и трейт Equal. Это значит: “у этого значения есть структурное равенство и hash, можно класть в HashMap, сравнивать через Equal.equals”.
Идея словами
import { Data, Equal } from 'effect';
const a = Data.struct({ name: 'github', interval: 30_000 });
const b = Data.struct({ name: 'github', interval: 30_000 });
const c = Data.struct({ name: 'github', interval: 60_000 });
Equal.equals(a, b); // true
Equal.equals(a, c); // false
a === b; // false (разные ссылки)
Data.struct(obj) это runtime-преобразование, которое возвращает объект, реализующий Equal и Hash. Под капотом он использует поля для расчёта хеша и проверки равенства.
Шаг 1 · Data.struct и Data.array
import { Data, Equal } from 'effect';
const setA = Data.array([1, 2, 3]);
const setB = Data.array([1, 2, 3]);
Equal.equals(setA, setB); // true
Data.array это аналогичная обёртка над массивом. Структурное равенство по элементам, hash по содержимому.
Шаг 2 · Data.Class для своих типов
Если нужен объект с методами и instanceof, бери Data.Class:
import { Data, Equal } from 'effect';
class MonitorConfig extends Data.Class<{
readonly url: string;
readonly interval: number;
readonly expectStatus: number;
}> {}
const a = new MonitorConfig({ url: 'x', interval: 30_000, expectStatus: 200 });
const b = new MonitorConfig({ url: 'x', interval: 30_000, expectStatus: 200 });
Equal.equals(a, b); // true
a instanceof MonitorConfig; // true
Класс автоматически получает:
- конструктор с типобезопасным
props-аргументом; Equal.equals(структурное равенство по полям);Hash.hash(производный hash);- readonly-поля по умолчанию.
Шаг 3 · Data.TaggedClass для размеченных типов
Если ты строишь discriminated union в коде (не через Schema.TaggedStruct, а вручную), бери Data.TaggedClass:
import { Data, Equal } from 'effect';
class ProbeSuccess extends Data.TaggedClass('ProbeSuccess')<{
readonly monitorId: string;
readonly status: number;
readonly elapsedMs: number;
}> {}
class ProbeFailure extends Data.TaggedClass('ProbeFailure')<{
readonly monitorId: string;
readonly reason: 'timeout' | 'network' | 'http-status';
}> {}
type Event = ProbeSuccess | ProbeFailure;
const a = new ProbeSuccess({ monitorId: 'github', status: 200, elapsedMs: 100 });
const b = new ProbeSuccess({ monitorId: 'github', status: 200, elapsedMs: 100 });
Equal.equals(a, b); // true
a._tag; // 'ProbeSuccess'
Data.TaggedClass(tag)<fields> это Data.Class плюс автоматическое поле _tag под нужным литералом. Тот же подход, что в Data.TaggedError (см. 03 · Tagged-ошибки).
Шаг 4 · Data.tagged для функциональных конструкторов
Иногда не хочется класс, а хочется фабрику-функцию:
import { Data } from 'effect';
interface ProbeSuccess {
readonly _tag: 'ProbeSuccess';
readonly monitorId: string;
readonly status: number;
}
const ProbeSuccess = Data.tagged<ProbeSuccess>('ProbeSuccess');
const evt = ProbeSuccess({ monitorId: 'github', status: 200 });
// evt: ProbeSuccess, evt._tag === 'ProbeSuccess'
Data.tagged<T>(tag) строит фабрику, которая принимает поля без _tag и проставляет его сама. Удобно, когда не нужен instanceof, но нужно структурное равенство и компактная запись.
Шаг 5 · Pulse · где это пригодится
В Pulse MonitorEvent сейчас собран через Schema.TaggedStruct плюс Schema.Union. Это правильно: события приходят из JSON, идут наружу через SSE. На границе с миром Schema незаменима.
Внутри программы у нас есть операционные объекты: MonitorConfig (распарсенный кусок PulseConfig), SlaSnapshot (моментальный срез транзакционного состояния), ResolvedHost (результат DNS-резолва). Они не пересекают сеть, для них Schema избыточна, а структурное равенство хочется (например, чтобы класть в HashMap без коллизий по ссылкам).
// pulse-<nick>/src/services/sla.ts (дополнение)
import { Data } from 'effect';
export class SlaSnapshot extends Data.Class<{
readonly active: 'primary' | 'fallback';
readonly consecutiveFailures: number;
readonly switchedAt: number | null;
}> {}
Теперь Equal.equals(a, b) работает по значению, и тесты на переход состояния пишутся короче:
expect(Equal.equals(snapshot, new SlaSnapshot({ active: 'fallback', consecutiveFailures: 0, switchedAt: null }))).toBe(true);
Что взять с собой
Data.struct(obj),Data.array(arr)базовые runtime-обёртки со структурным равенством.Data.Class<{...}>для своих классов сEqual,Hash,instanceof.Data.TaggedClass(tag)<{...}>для размеченных классов.Data.tagged<T>(tag)если хочется функциональный конструктор без класса.- На границе с миром,
Schema. Внутри программы,DataилиSchema.Class(см. 02 · Schema, Раздел 9).
Pulse · вклад этого урока
К концу урока в pulse-<nick> появляется директория test/ и пара рефакторов в src/ по code-style guidelines.
test/ · полный набор
test/
probe.test.ts // probe + recordResult против HttpStub
schedule.test.ts // расписание через TestClock
sla.test.ts // circuit-breaker на TxRef
events.test.ts // round-trip Schema + Equal на Data.Class
main-test.test.ts // smoke: MainTest целиком собирается
Каждый файл следует одному шаблону:
import { describe, it, expect } from '@effect/vitest';
import { Effect } from 'effect';
import { MainTest } from '~/test/main-test.ts';
describe('feature', () => {
it.effect('сценарий', () =>
Effect.gen(function* () {
// тело теста
}).pipe(Effect.provide(MainTest({ /* стабы */ }))),
);
});
Рефактор по guidelines
Три места в src/, где после прохождения курса хочется поправить руками:
src/lib/tap-log.tsновый файл сdual-хелперомtapLog, замены руками-написанномуEffect.tap(() => Console.log(...))в десяти местах.src/events.tsдобавленEventIdчерезBrand.nominalиmakeEventIdчерезRandom.nextIntBetween. Все события, попадающие вStorage, обзаводятсяid-полем.src/services/sla.tsSlaSnapshotпереведён наData.Classдля структурного равенства; тесты на переход стали короче на четыре строки.
pnpm typecheck зелёный, pnpm lint зелёный, pnpm test зелёный за 1.5 секунды на всём наборе.
Финал серии · чек-лист “когда Effect окупается”
Серия закрыта. Перед тем как тащить Effect в новый проект, прогони список из шести пунктов. Если выполнено три и больше, Effect окупается. Если меньше, оставь neverthrow или голый async/await, не плати за runtime.
Пункт 1 · Явное окружение
- В программе несколько реализаций одного контракта: реальный HTTP-клиент и in-memory, реальная БД и фикстурная, реальные часы и
TestClock. - Зависимости тянутся через 3+ уровня функций (классический prop drilling, см. 04 · Сервисы и Layer).
- DI-контейнеры с runtime-разрешением (tsyringe, awilix) уже больно: ошибки прилетают в production, не в компайл.
Если так: третий параметр R и Layer выгоднее любого ad-hoc DI. Программу подменяешь от точки до точки без vi.mock.
Пункт 2 · Типизированные ошибки
- В сервисе больше двух классов ожидаемых ошибок, на которые вызывающий реагирует по-разному.
try/catch (e: unknown)уже не справляется: где-то надо retry, где-то fallback, где-то прокинуть наверх.- На ревью регулярно ловится “забыл обработать вот этот кейс”.
Если так: Data.TaggedError плюс catchTag плюс Match.exhaustive дают компайл-таймовую гарантию (см. 03 · Tagged-ошибки).
Пункт 3 · Structured concurrency
- Программа запускает параллельные операции, которые должны корректно прерываться (отмена медленного запроса, race с резервным URL, ограничение конкурентности).
Promise.allсAbortControllerуже больно: сигналы не пропагируются автоматически, ошибки в одной ветке не отменяют другие.- Висят файберы, утекают сокеты,
Ctrl+Cоставляет процесс.
Если так: Effect.forkScoped плюс Fiber.interrupt плюс Scope гарантируют, что родитель дожидается всех детей и убивает их при выходе (см. 06 · Файберы).
Пункт 4 · Расписания, retry, timeout
- В программе 5+ мест с retry/timeout/repeat, и каждое написано чуть по-разному.
- Хочется задать политику ретраев декларативно и переиспользовать.
setTimeout/Promise.raceуже разъехались по коду, тесты на них флакают.
Если так: Schedule плюс Effect.retry/Effect.timeout/Effect.race это первоклассное решение (см. 11 · Runtime и Schedule).
Пункт 5 · Observability и единый runtime
- Программа крутится в нескольких точках входа: CLI, HTTP-сервер, фоновый воркер, lambda-обработчик. Хочется общий граф зависимостей.
- Нужен один логгер, один трейсер, один metrics-клиент на всё.
Ctrl+Cили SIGTERM должен корректно закрыть всё, что внутри.
Если так: ManagedRuntime плюс MainLive плюс runtime.dispose() гарантируют единый граф и корректное завершение (см. 04 · Сервисы и Layer, Раздел 8 и 12 · CLI и Terminal).
Пункт 6 · Тесты с детерминированным временем
- В программе есть логика на времени: расписание, таймауты, агрегация по окнам, дебаунс.
- Тесты на это либо медленные, либо флакающие.
vi.useFakeTimersуже использовал и страдал (особенно когда внутриPromise, который сам зовётsetTimeout).
Если так: TestClock плюс it.effect решают честно. Любой тест на расписание занимает 0мс реального времени и детерминирован между запусками (см. этот урок, Раздел 2).
Когда не тащить Effect
- Программа из 200 строк, одна точка входа, одна зависимость, одна обработка ошибок. Тут
Effectэто overkill,neverthrow.Resultплюсasync/awaitдешевле и проще. - Команда не готова выделить неделю на чтение доки и привычку. На “переход с Promise” с нуля уходит примерно 80 часов, после этого продуктивность возвращается, но первая неделя честно болит.
- Размер бандла критичен (например, embedded в браузер для рекламной кампании).
effectэто десятки килобайт gzipped, против ноля для гологоasync/await.
Итого
Если по шести пунктам у тебя три и больше “да”, Effect окупается. Pulse, который ты собрал за тринадцать уроков, имеет шесть из шести: DI через Layer, шесть классов ошибок, Effect.forkScoped плюс interrupt по SIGINT, Schedule.spaced с jitter, единый ManagedRuntime на CLI и HTTP, TestClock для расписания. Это рабочий тест, который ты переносишь на свой производственный проект и понимаешь, окупается ли.
ДЗ
Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-13.
Дальше
Ядро закрыто. Тринадцать уроков, один сквозной проект, один граф зависимостей, две поверхности, тестовый чек-лист. У тебя на руках Pulse, который умеет мониторить адреса, переключаться на резервный URL, писать события в JSONL батчами, отдавать SSE-поток в браузер и закрываться по SIGINT без висящих файберов.
Дальше раздел продолжается двумя блоками. 14 · DDD-типы и следующие три урока это functional DDD на Effect: Decider, Event Store, CQRS-проекции. 18 · Observability и до конца раздела это прод-обвязка: телеметрия, HttpClient и API, конфигурация и секреты, Schema вглубь, приёмники потоков, стандартная библиотека и инструменты. Оба блока самодостаточны, порядок между ними на твой выбор.
Параллельно хорошо идёт практика. Два воркшопа курса ложатся как продолжение:
- 02-cs · io-monad. Воркшоп, где ты руками собирал
IOсSymbol.iterator-do, ошибочный канал, интерпретатор. После этой серии возвращайся туда, и часть 8.4 (генератор-do наyield*) будет читаться как роман: ты уже видел, как Effect делает то же самое на типах. - 02-cs · orm-workshop. Собственный ORM поверх Drizzle-схемы. Effect-овые сервисы и
Layerхорошо ложатся как замена ручного DI:QueryRunnerэто сервис,Transactionэто scoped Layer,migrateэто Effect-программа сSchedule.recurs(migrationsCount).
Параллельно полезно перечитать:
- 02-cs · 08 Системы эффектов, теоретическая база. Серия 25-effect это её прямое продолжение на конкретной библиотеке.
- 04-ts · 05 Effect-TS на практике, обзорный урок, в котором ты впервые увидел
Effect<A, E, R>. Перечитай: то, что год назад казалось плотной декларацией, теперь читается за секунду. - В репозитории курса лежат
examples/our-orm/иexamples/our-io/, маленькие учебные пакеты с собственнымvitest.config.ts. Там можно потрогать руками идеи Effect (интерпретатор, scoped-ресурсы, типизированные ошибки) без самой библиотеки.
И последнее. Effect это инструмент, не идеология. Каждый кусок, который ты сегодня закрыл (Layer, Schedule, Stream, транзакции, TestClock), решает одну конкретную задачу. Когда твой следующий проект её ставит, ты вспомнишь нужный примитив, прочитаешь сигнатуру за минуту и встроишь за день. Это и есть цель серии: не убедить, что Effect лучше всего, а дать тебе живые ситуации, в которых ты сам узнаешь, окупается он или нет.