Tagged-ошибки: actionable vs unactionable, catchTag, Cause, matchEffect
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Tagged-ошибки: actionable vs unactionable, catchTag, Cause, matchEffect
Сцена · две дорожки рельса
В уроке 01 · Effect intro мы прочитали Effect<A, E, R> слева направо. Параметр A, что вернётся при успехе. Параметр E, что вернётся при ожидаемой ошибке. Параметр R, что нужно подложить, чтобы программа поехала. Сегодня мы садимся в канал E и обживаем его всерьёз.
Эффект едет по двум рельсам. На правой дорожке типизированные ошибки, они видны в сигнатуре, на них компилятор сам напомнит. На левой defects, и они тоже проходят через рантайм, но прячутся в специальном дереве причин (Cause). У этого деления есть теоретическая часть и практическая, и обе важны.
Теоретическая часть: что такое actionable error и что такое unactionable. Практическая: как руками держать обе дорожки в порядке через Data.TaggedError, catchTag, Effect.matchEffect, Match.value, Effect.sandbox, Effect.die, Effect.orDie. К концу урока у Pulse появится семейство из шести классов ошибок и две функции, formatAlert и recordResult, которые показывают границу между чистым и эффектным ветвлением.
Сцена · actionable vs unactionable
В уроках по Promise и try/catch ошибки делят на “ожидаемые” и “неожиданные”. Это удобный, но мутный язык. Куда полезнее другая пара слов: actionable и unactionable.
Actionable, ошибка, на которую вызывающий может что-то сделать: повторить, подменить дефолтом, переключиться на резервный адрес, показать пользователю, записать метрику. Если у тебя в E стоит NetworkError, ты тем самым говоришь читателю кода: “вот тут можно реагировать”. Этот контракт читают глазами на ревью.
Unactionable, ошибка, на которую конкретный вызывающий уже ничего не сделает. БД лежит, и пока её не подняли, любая ретрай-петля будет жечь байты. Кончилась память, и любой запасной путь тоже её ест. Программный баг (null там где ожидался User), и его правильное поведение, упасть громко и попасть в Sentry. Такие ошибки прятать в E вредно: они шумят в типе, и вызывающие пишут catchTag “чтобы скомпилировалось”, не понимая, что делать.
┌──────────────────────────────────┐
│ Effect<A, E, R> │
└────────┬─────────────────────────┘
│
┌──────────────────┴──────────────────┐
│ │
▼ ▼
┌────────────────────┐ ┌─────────────────────┐
│ E (actionable) │ │ Defects │
│ fail-канал │ │ Cause.die │
│ │ │ │
│ NetworkError │ │ programmer error │
│ HttpStatusError │ │ база лежит │
│ TimeoutError │ │ out of memory │
│ ConfigParseError │ │ invariant violated │
│ ... │ │ Effect.die(...) │
└────────────────────┘ └─────────────────────┘
│ │
catchTag, Effect.exit + Cause,
catchTags, Effect.sandbox,
matchEffect, глобальный обработчик
orElse (Sentry, лог)
Правило, которое работает каждый день: если в твоём типе появился новый класс ошибки, спроси, кто и как его поймает. Если ответ “никто и никак”, это unactionable. Переведи в defect через Effect.die или Effect.orDie. Если ответ “вот этот вызывающий, через catchTag('...') подменит дефолтом”, оставляй в E.
Это не предписание сверху, это инструмент мышления. Effect только подсвечивает решение в типе: что попало в E, видно всем, кто читает сигнатуру. Дальше работает обычное код-ревью.
Сквозной проект · Pulse · что доедет в этот раз
В уроке 01 · Effect intro у тебя в pulse-<nick> появились src/errors.ts (один класс NetworkError) и src/probe.ts (одна функция probe). В уроке 02 · Schema в errors.ts въехал ConfigParseError. К концу этого урока в errors.ts будут шесть классов:
NetworkError // хост недоступен, DNS не зарезолвился, ECONNREFUSED
HttpStatusError // ответ пришёл, но статус не тот, что ожидали
BodyContractError // тело не соответствует ожидаемой схеме
TimeoutError // не уложились в `timeout`
ConfigParseError // pulse.config.json не парсится
StorageError // запись в журнал упала
И один экспортируемый алиас PulseError для их объединения. Канал E всей программы Pulse будет этим алиасом.
Появится src/matching.ts с двумя функциями:
formatAlert(error: PulseError): string, чистая. Вход размеченное объединение, выход строка для алерта. Реализована черезMatch.valueплюсMatch.exhaustive, компилятор сам ругнётся, если добавишь седьмой класс и забудешь его обработать.recordResult(monitorId, probe), эффектная. ПревращаетEffect<ProbeResult, NetworkError | HttpStatusError | TimeoutError>вEffect<MonitorEvent>черезEffect.matchEffect. Успех становитсяProbeSuccess, провал ветвится по_tagвProbeFailureс правильнымreason.
И учебная заплатка divideOrDie, чтобы пощупать Effect.die и Cause.hasDies руками.
Раздел 1 · Actionable vs unactionable
Сцена
Ты пишешь сервис, который собирает картинку из трёх независимых источников: миниатюра, размеры, метаданные. Каждый источник может упасть. Дальше на ревью кто-то спрашивает: “а почему у тебя три типа ошибок в сигнатуре processImage?”
Без Effect этот вопрос не поднимается. Ошибки всплывают через try/catch в типе unknown, кричат в console.error, и каждый вызывающий молится, что его внешний try/catch поймает нужное. С Effect это перевёрнуто: что в E, то контракт.
Идея словами
Контракт по типу ошибки это обещание вызывающему: “ты можешь на это разумно отреагировать”. Если разумно реагировать нельзя, это не actionable, и в E ему делать нечего.
Как пользоваться правилом ежедневно:
- у тебя на ветке появился новый класс ошибки, спроси: “что сделает вызывающий? повтор? запасной путь? показ пользователю?”;
- если ответа нет, это defect. Используй
Effect.die,Effect.orDie, либо не лови ошибку вtryPromise-обёртке (хотя так делать не стоит, лучше явно); - если ответ есть, оставь класс в
E, но проверь, что этот вызывающий действительно может на него реагировать. Иначе ошибку надо обработать раньше, до того как она долетит сюда.
Шаг 1 · Утечка ошибок наверх
Сначала ситуация утечки. Каждая под-функция отдаёт свою ошибку, родитель собирает их в одну широкую сигнатуру, но что с ними делать, не знает.
import { Effect } from 'effect';
declare const processThumbnail: Effect.Effect<Thumbnail, ThumbnailError>;
declare const calculateDimensions: Effect.Effect<Dimensions, DimensionError>;
declare const extractMetadata: Effect.Effect<Metadata, MetadataError>;
const processImage = Effect.gen(function* () {
const thumbnail = yield* processThumbnail;
const dimensions = yield* calculateDimensions;
const metadata = yield* extractMetadata;
return { thumbnail, dimensions, metadata };
});
// processImage: Effect<{...}, ThumbnailError | DimensionError | MetadataError>
Тип честный, но бесполезный. Вызывающий processImage понятия не имеет, какие из этих трёх ошибок он способен обработать. Он напишет Effect.catch на всё подряд, и провал миниатюры пройдёт по той же дороге, что провал размеров и метаданных, хотя реакции должны быть разными.
Шаг 2 · Обработка у вызывающего
То же самое, но каждый источник обработан там, где известна реакция. На уровне processImage мы знаем, что миниатюра, картинка с дефолтом подойдёт, размеры можно посчитать оценкой, а метаданные если упали, упала вся операция.
const processImage = Effect.gen(function* () {
const thumbnail = yield* processThumbnail.pipe(
Effect.catch(() => Effect.succeed(defaultThumbnail)),
);
// ThumbnailError обработан, наверх не уходит
const dimensions = yield* calculateDimensions.pipe(
Effect.orElse(() => calculateFallbackDimensions),
);
// DimensionError обработан через резервную ветку
const metadata = yield* extractMetadata;
// MetadataError остаётся, и это сознательное решение:
// без метаданных весь processImage не имеет смысла
return { thumbnail, dimensions, metadata };
});
// processImage: Effect<{...}, MetadataError>
В типе осталась одна ошибка, и она по-настоящему actionable на уровне выше: “у нас не получилось извлечь метаданные, отдаём 502 и просим повторить через минуту”. Это и есть та самая дисциплина “don’t leak errors up the stack”.
Шаг 3 · Когда orDie уместен
Если из трёх ошибок ни одна не actionable на этом уровне (например, мы внутри background-воркера и ничего внятного с ошибкой сделать не можем), переведи всё в defect:
const processImage = Effect.gen(function* () {
const thumbnail = yield* processThumbnail;
const dimensions = yield* calculateDimensions;
const metadata = yield* extractMetadata;
return { thumbnail, dimensions, metadata };
}).pipe(Effect.orDie);
// processImage: Effect<{...}, never>
Effect.orDie забирает все ожидаемые ошибки и переводит в defect. Сверху это значит “программа упадёт в общий обработчик runFork/Sentry, никаких типизированных продолжений на этом канале нет”. Иногда это правильно. Главное, что ты делаешь это сознательно, а не “забыл обработать”.
Что взять с собой
- Канал
Eэто контракт “ты можешь на это разумно отреагировать”. Если ответа нет, вEему не место. - Обрабатывай ошибки у вызывающего, не давай им бубулькать через пять уровней. Пять уровней не знают, что делать с твоим
ThumbnailError. - Сознательное превращение в defect через
Effect.die/Effect.orDieэто рабочий инструмент. Не считать его “трусостью” или “сдавливанием”.
Раздел 2 · Tagged-ошибки через Data.TaggedError
Сцена
Ты уже видел _tag. У Exit, Option, Result, всех Schema.TaggedStruct. Это та самая discriminated union-конвенция, на которой стоит вся идиоматика Effect. Свои ошибки тоже строй на ней, тогда библиотека всё узнает с полпинка.
Идея словами
Вместо встроенного Error пишешь класс с полем _tag:
class CustomError {
readonly _tag = 'CustomError';
constructor(readonly message: string) {}
}
Этого хватит, чтобы ловить через Effect.catchTag('CustomError', ...). Но генераторам не нравится синтаксический шум: return yield* Effect.fail(new CustomError({ message: '...' })) это многовато слов на каждый провал. Effect это решает через Data.TaggedError.
import { Data } from 'effect';
class CustomError extends Data.TaggedError('CustomError')<{
message: string;
}> {}
Что из этого получается:
_tagна инстансе, выставлен автоматически;- поля типизированы, в конструктор уходит объект (
new CustomError({ message: '...' })); - инстанс сам по себе Effect:
yield*его внутриgen-блока без обёртки вEffect.fail; - играет с
pipe,mapError,catchTag,catchTagsиз коробки.
Шаг 1 · Минимальный пример
import { Data, Effect } from 'effect';
class Boom extends Data.TaggedError('Boom')<{ reason: string }> {}
const program = Effect.gen(function* () {
if (Math.random() < 0.5) {
return yield* new Boom({ reason: 'orelse' });
}
return 42;
});
// program: Effect<number, Boom, never>
Обрати внимание: в gen мы написали return yield* new Boom(...). Никакого Effect.fail, никакого throw. Класс уже Effect, его можно yield*-ить как любой другой Effect. Внутри он работает как Effect.fail(new Boom(...)).
return перед yield* нужен ради сужения типов: без return TypeScript считает, что после yield* управление ушло, но не помнит, что именно ушло. С return yield* все следующие шаги корректно вырезаны, и A в типе сужается.
Шаг 2 · Шесть ошибок Pulse сразу
В pulse-<nick>/src/errors.ts сегодня появляются все шесть. Файл будет таким:
import { Data } from 'effect';
/** Сетевая ошибка: DNS не зарезолвился, ECONNREFUSED, ECONNRESET. */
export class NetworkError extends Data.TaggedError('NetworkError')<{
readonly url: string;
readonly cause: unknown;
}> {}
/** HTTP-ответ пришёл, но статус не тот, что ожидали. */
export class HttpStatusError extends Data.TaggedError('HttpStatusError')<{
readonly url: string;
readonly status: number;
readonly expected: number;
}> {}
/** Тело ответа не соответствует ожидаемой схеме. */
export class BodyContractError extends Data.TaggedError('BodyContractError')<{
readonly url: string;
readonly cause: unknown;
}> {}
/** Запрос не завершился в отведённое время. */
export class TimeoutError extends Data.TaggedError('TimeoutError')<{
readonly url: string;
readonly timeoutMs: number;
}> {}
/** Падение парсинга конфига: файл не открылся, JSON битый, схема не сошлась. */
export class ConfigParseError extends Data.TaggedError('ConfigParseError')<{
readonly path: string;
readonly cause: unknown;
}> {}
/** Ошибка записи в Storage: диск кончился, handle закрыт раньше времени. */
export class StorageError extends Data.TaggedError('StorageError')<{
readonly cause: unknown;
}> {}
/** Объединение всего, что Pulse считает ожидаемой ошибкой. */
export type PulseError =
| NetworkError
| HttpStatusError
| BodyContractError
| TimeoutError
| ConfigParseError
| StorageError;
Заметь несколько вещей:
- Имя класса и имя
_tagсовпадают. Это конвенция, не правило, но если ты её нарушишь, читать код будет тяжелее. - Поля
readonly. Tagged-ошибка это значение, мутация не нужна. - В
cause: unknownмы кладём то, что прилетело снизу:Errorотfetch,SyntaxErrorотJSON.parse,SchemaErrorотSchema.decodeUnknownEffect. Сама по себе тип-обёртка нашу ошибку ничем не загрязнила, мы лишь добавили разметку. PulseErrorэто дисциплина. Любая публичная функция Pulse, которая может упасть, сужает свойEдо подмножестваPulseError. Никаких голыхErrorнаружу.
Шаг 3 · Tagged-ошибки тоже композитятся
Поскольку каждый класс уже Effect, с ним можно работать, как с любым Effect:
import { Effect } from 'effect';
const fail = new NetworkError({ url: 'https://x', cause: 'ECONNREFUSED' });
// fail: NetworkError, и одновременно Effect<never, NetworkError, never>
const remapped = fail.pipe(
Effect.mapError(() => new HttpStatusError({ url: 'https://x', status: 500, expected: 200 })),
);
// remapped: Effect<never, HttpStatusError, never>
Раньше нам приходилось писать Effect.fail(new NetworkError(...)) ради того, чтобы pipe стал доступен. Здесь он доступен прямо на инстансе. Это и есть тот сахар, который Effect называет yieldable error.
Что взять с собой
Data.TaggedError(name)<Fields>это короткая запись для класса с_tag, конструктором, типизированными полями и yieldable-режимом._tagсовпадает с именем класса. Поляreadonly. Объединение всех твоих ошибок (PulseError) держится в одном экспортируемом алиасе.- В
genпишиreturn yield* new SomeError({...}). Это короче и точнее, чемEffect.fail(new SomeError({...})).
Раздел 3 · Точечная обработка: catchTag, catchTags, catch
Сцена
У тебя в типе Effect<A, NetworkError | HttpStatusError | TimeoutError, R>. Ты хочешь поймать только NetworkError, оставить остальные нетронутыми. Без размеченности это требует ручного instanceof. С размеченностью это одна строка.
Идея словами
Три имени, три уровня охвата.
| Комбинатор | Что ловит | Что остаётся в типе |
|---|---|---|
Effect.catchTag('Tag', handler) | только ошибки с этим _tag | остальные классы из объединения |
Effect.catchTags({ Tag1: h1, Tag2: h2, ... }) | по нескольку тегов сразу | то, что не упомянуто в объекте |
Effect.catch(handler) | любую ошибку из E | never, канал ошибок очищен (если не вернёшь новую) |
Имя Effect.catch короткое не случайно: это самый общий из трёх, и вокруг него собрано целое семейство с той же приставкой. catchCause берёт весь Cause, catchDefect ловит только defect, catchFilter ловит по предикату, catchIf по условию. Приставка catch плюс уточнение, что именно ловим.
В обработчике error уже сужен до конкретного класса. Никаких if (e._tag === '...') руками: компилятор делает это за тебя по сигнатуре.
Шаг 1 · catchTag · одна ветка
import { Effect } from 'effect';
import { NetworkError, HttpStatusError, TimeoutError } from './errors.ts';
declare const fetchWithRetry: Effect.Effect<
Response,
NetworkError | HttpStatusError | TimeoutError
>;
const tolerateNetwork = fetchWithRetry.pipe(
Effect.catchTag('NetworkError', (error) => {
// error: NetworkError, поля типизированы
return Effect.succeed(new Response(null, { status: 503 }));
}),
);
// tolerateNetwork: Effect<Response, HttpStatusError | TimeoutError, never>
NetworkError исчез из объединения, HttpStatusError и TimeoutError остались. На уровне типа сразу видно, что мы этот класс обработали.
Шаг 2 · catchTags · сразу несколько
const tolerateMany = fetchWithRetry.pipe(
Effect.catchTags({
NetworkError: (e) => Effect.succeed(new Response(null, { status: 503 })),
TimeoutError: (e) =>
Effect.fail(new HttpStatusError({ url: e.url, status: 504, expected: 200 })),
}),
);
// tolerateMany: Effect<Response, HttpStatusError, never>
catchTags принимает объект, ключи это имена тегов. Каждое плечо может либо вернуть Effect.succeed, либо Effect.fail (даже с новым типом, как тут: TimeoutError мы перевели в HttpStatusError).
Шаг 3 · catch · сметаем всё
const safe = fetchWithRetry.pipe(
Effect.catch(() => Effect.succeed(new Response(null, { status: 503 }))),
);
// safe: Effect<Response, never, never>
Effect.catch это “забить дефолтом на любое падение”. В обработчике error имеет тип NetworkError | HttpStatusError | TimeoutError, ты можешь по нему ветвиться. После такого catch в E стоит never, программа гарантированно успешна.
Это удобно для эндпоинтов вроде “/health”, где любой провал ответа равен “503”. Но в продуктовом коде чаще лучше точечный catchTag, чтобы не поймать больше, чем хотел.
Шаг 4 · mapError · переименование без обработки
Иногда ты не хочешь обрабатывать ошибку, ты хочешь только сменить класс. Это делается через Effect.mapError:
const remapped = fetchWithRetry.pipe(
Effect.mapError((error) => {
if (error._tag === 'NetworkError') return new StorageError({ cause: error });
return error;
}),
);
// remapped: Effect<Response, StorageError | HttpStatusError | TimeoutError, never>
Полезно на границах модулей: внутри модуля мы знаем про NetworkError, на границу выпускаем уже общий StorageError. У mapError нет ветвления по тегам, ты сам разбираешься через _tag. Если хочешь по тегам, бери catchTag плюс Effect.fail обратно.
Шаг 5 · Чем отличается от try/catch в обычном TS
Обрати внимание на главное: обработчик не получает unknown. В TypeScript-catch (e) любая ошибка прилетает как unknown или any, и ты сам её должен сужать через instanceof, typeof, кастомные guard-ы. В Effect-catchTag обработчик получает уже сужённый класс, со всеми типизированными полями. Это автоматическое сужение по _tag, без ручной работы.
Вторая важная разница: try/catch ловит всё подряд, включая баги программиста. catchTag ловит только тот тег, который ты назвал. Defects (то есть Cause.die) проходят мимо, и это хорошо: ты не глушишь баги под видом ошибок сети.
Что взять с собой
catchTagдля одной ветки,catchTagsдля нескольких,catchдля всех. Тип в обработчике уже сужен по_tag.mapErrorдля смены класса без ветвления. Удобно на границах модулей.catchTagловит только actionable_tag, defects идут мимо. Это и есть та “правильная” дисциплина, которой не даётtry/catch.
Раздел 4 · Cause · что лежит под капотом
Сцена
Effect.runPromiseExit(p) возвращает не Promise<A>, а Promise<Exit<A, E>>. У Exit две ветки: Success(a) и Failure(cause). И вот в этом cause лежит полный протокол падения: твои Fail-ошибки, defect-ы, прерывания, сложение нескольких параллельных провалов в одном файбере. Большую часть времени он тебе не нужен. Иногда нужен.
Идея словами
Cause<E> устроен предельно просто: это обёртка над плоским массивом причин.
interface Cause<E> {
readonly reasons: ReadonlyArray<Reason<E>>;
}
type Reason<E> = Fail<E> | Die | Interrupt;
Причин ровно три вида:
Тег _tag | Что значит | Источник |
|---|---|---|
Fail | ожидаемая ошибка | Effect.fail, yield* над Data.TaggedError |
Die | defect, programmer error | брошенный throw в обёртке без catch, Effect.die, Effect.dieMessage |
Interrupt | файбер прерван | Fiber.interrupt, таймаут, Ctrl+C через signal handler |
Отдельных веток “пусто”, “два параллельных провала” и “два последовательных провала” нет: пустой массив reasons это и есть пустая причина, а несколько провалов просто лежат рядом в одном массиве. Ради этого структуру и распрямили: раньше приходилось рекурсивно ходить по дереву, теперь достаточно одного цикла по cause.reasons.
В практике ты будешь различать все три: Fail (твои tagged-ошибки), Die (баги), Interrupt (мы вернёмся к нему в уроке 06 про файберы).
Шаг 1 · Достать Cause через Effect.exit
Effect.exit это удобная обёртка. Она превращает Effect<A, E, R> в Effect<Exit<A, E>, never, R>: ошибки больше нет в канале E, она вшита в результат как Exit-значение.
import { Cause, Effect } from 'effect';
declare const program: Effect.Effect<number, NetworkError>;
const exit = await Effect.runPromise(Effect.exit(program));
if (exit._tag === 'Failure') {
for (const reason of exit.cause.reasons) {
if (Cause.isFailReason(reason)) {
reason.error; // NetworkError
} else if (Cause.isDieReason(reason)) {
reason.defect; // unknown, "что попало"
}
}
} else {
exit.value; // number
}
Гарды бывают двух уровней, и путать их не стоит. Cause.isFailReason, Cause.isDieReason, Cause.isInterruptReason работают по одной причине из массива. Cause.hasFails, Cause.hasDies, Cause.hasInterrupts отвечают на вопрос про весь Cause целиком, “есть ли там хоть одна такая причина”. Все шесть это type guard-ы, после успешной проверки тип сужается, и ты безопасно достаёшь поля.
Если тебе нужна не проверка, а само значение, есть пара экстракторов: Cause.findErrorOption(cause) вернёт Option с первой ожидаемой ошибкой, Cause.findDefect(cause) достанет первый defect.
В реальном коде, когда у тебя в типе E стоит только NetworkError, ты знаешь, что причина-Fail несёт его. Но Die всегда unknown (defect не сидит в типе), его нужно проверять руками.
Шаг 2 · Effect.sandbox · поднять Cause в канал ошибок
Иногда хочется работать с Cause через тот же catchTag-аппарат, что и с обычными ошибками. Для этого есть Effect.sandbox: он переводит Cause в канал E.
const sandboxed = Effect.sandbox(program);
// sandboxed: Effect<number, Cause<NetworkError>, never>
const handledBySandbox = sandboxed.pipe(
Effect.catch((cause) =>
Cause.hasDies(cause)
? Effect.sync(() => {
console.error('defect:', Cause.findDefect(cause));
return -1;
})
: Effect.succeed(0),
),
);
После sandbox ошибка имеет тип Cause<E>, и её ловит обычный Effect.catch, как любую другую ошибку на канале E. Только разбирать её приходится не по _tag самого Cause (у плоской структуры его нет), а через hasDies, hasFails, hasInterrupts и экстракторы. Это удобный приём для отладки: “хочу один раз заглянуть, что там в Cause лежит, и пойти дальше”.
Обратно Cause кладётся связкой Effect.catch плюс Effect.failCause: поймал причину, вернул её же в канал причин. Отдельного unsandbox для этого больше нет, и он не нужен.
Шаг 3 · Effect.catchCause · обработка Cause без sandbox
Если поднимать Cause наверх не хочется (sandbox меняет тип), бери catchCause:
const handled = program.pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
if (Cause.hasDies(cause)) {
yield* Effect.logError('программа упала из-за бага', { cause: Cause.pretty(cause) });
return -1;
}
if (Cause.hasInterruptsOnly(cause)) {
yield* Effect.logInfo('программа прервана пользователем');
return 0;
}
// дальше, наверное, Fail
yield* Effect.logError('ожидаемая ошибка', { cause: Cause.pretty(cause) });
return -1;
}),
),
);
// handled: Effect<number, never, never>, всё закрыто
Cause.pretty(cause) рисует читаемый разбор всех причин, отлично для логов. Cause.hasDies, Cause.hasInterruptsOnly это уже знакомые гарды на уровне всего Cause.
Что взять с собой
Cause<E>это плоский массивreasonsс тремя видами причин:Fail,Die,Interrupt.Effect.exitдостаётExit<A, E>, в котором уже лежит Cause. Это самый частый способ “посмотреть, что упало”.Effect.sandboxподнимает Cause в каналE, дальше его ловит обычныйEffect.catch. Полезно для разборок и для границ.Effect.catchCauseпринимает Cause напрямую, безsandbox. Удобно для финального обработчика “верхнего уровня”.
Раздел 5 · Превращаем typed-ошибку в defect: die, dieMessage, orDie
Сцена
В Разделе 1 мы договорились: если конкретный вызывающий не может ничего сделать с ошибкой, она не actionable, ей место в defect. Теперь смотрим, как вручную делать это превращение.
Идея словами
Три инструмента:
| Имя | Сигнатура | Когда брать |
|---|---|---|
Effect.die(value) | Effect<never, never, never> | превратить произвольное значение в defect (любой unknown) |
Effect.dieMessage('text') | Effect<never, never, never> | defect только с текстом, обёрнутым в RuntimeException |
Effect.orDie | Effect<A, E, R> -> Effect<A, never, R> | перевести все ошибки канала E в defect |
После любого из них в типе E появляется never (для orDie) либо ошибка не появляется вовсе (die/dieMessage это уже Effect<never, never, never>).
Шаг 1 · Effect.die на инвариант
Самый частый сценарий: ты уверен, что в этом месте нужное значение должно быть, и если его нет, это баг твоего кода, а не ситуация, на которую кто-то реагирует.
import { Effect } from 'effect';
const divideOrDie = (a: number, b: number): Effect.Effect<number> =>
b === 0
? Effect.die(new Error('division by zero is a programmer error'))
: Effect.succeed(a / b);
// divideOrDie: Effect<number, never, never>
Заметь: в E нет Error. Defect не виден в типе. Если ты вызовешь divideOrDie(1, 0), программа упадёт через Cause.die, и снаружи это поймать можно только через Effect.exit, sandbox, или catchCause. А не через catchTag.
Это часть контракта: вызывающий смотрит на сигнатуру и видит, что ошибки нет. Значит, мы обещаем не падать в обычной работе. Если падает, ты обновляешь Sentry-алёрт и идёшь чинить, не пишешь catchTag.
Шаг 2 · Effect.dieMessage для коротких ассертов
Когда сообщения хватает, и оборачивать в свой Error лень:
const ensurePositive = (n: number): Effect.Effect<number> =>
n > 0 ? Effect.succeed(n) : Effect.dieMessage(`expected positive, got ${n}`);
Под капотом dieMessage оборачивает текст в обычный Error и кладёт его причиной Die. На печати через Cause.pretty это выглядит понятно.
Шаг 3 · Effect.orDie · сметаем всё
Когда у тебя на руках Effect<A, E, R> и ты решил, что ни один из классов в E тут не actionable, бери orDie:
import type { ConfigParseError } from './errors.ts';
declare const loadConfig: Effect.Effect<PulseConfig, ConfigParseError>;
// в `main`-программе мы решили: если конфиг не парсится, это конец, нет смысла
// продолжать. Превращаем в defect, и сверху ловим через единый `catchCause`.
const main = loadConfig.pipe(Effect.orDie);
// main: Effect<PulseConfig, never, never>
После orDie в E стоит never. Любой ConfigParseError, который тут произойдёт, прилетит в Cause-Die. Вызывающий может либо ничего не делать (умрёт громко), либо поставить общий обработчик через catchCause.
Шаг 4 · catchTag(... -> Effect.die(...)) · точечный downgrade
Иногда из объединения хочется одну ошибку перевести в defect, остальные оставить:
declare const fetchUser: Effect.Effect<User, NetworkError | BodyContractError>;
const program = fetchUser.pipe(
Effect.catchTag('BodyContractError', (error) =>
Effect.die(new Error(`api contract drift: ${error.url}`)),
),
);
// program: Effect<User, NetworkError, never>
Логика: BodyContractError означает “API сервера сломан, наш код больше не сходится по схеме”. Это не ситуация, на которую пользователь как-то отреагирует. Это ситуация “разбудите дежурного бэкендера”. Мы переводим её в defect, и в E остаётся только actionable NetworkError.
Шаг 5 · Когда не надо
orDie это удобное сокращение, у него есть тёмная сторона. Если ты применишь его слишком высоко в стеке, ты молча скушаешь все actionable-ошибки на пути. Все ваши Sentry-алёрты будут на одно и то же сообщение, причины никто не увидит.
Правило: orDie ставь там, где сознательно решаешь, что вверх по стеку реакция уже не нужна. Если сомневаешься, бери catchCause и явно различай ветки. Чуть длиннее, зато честнее.
Что взять с собой
Effect.die(value)это явный defect. ТипEffect<never, never, never>.Effect.dieMessage('...')короткий defect для ассертов и инвариантов.Effect.orDieсметает все ошибки каналаEв defect-канал. Используй сознательно, не “чтобы скомпилировалось”.- Точечный
catchTag('Tag', error => Effect.die(error))это приём для перевода одной ветки в defect.
Раздел 6 · Чистый матчинг через Match.value
Сцена
formatAlert(error: PulseError): string. На вход размеченное объединение из шести классов, на выход одна строка. В Effect-канал не залезаем, побочных эффектов нет, никакого gen. Это работа для чистого матчера, и в effect он встроенный.
Идея словами
effect/Match это аналог ts-pattern, без внешней зависимости. Тебе хватит трёх вещей:
Match.value(input)начинает разбор;Match.tag('TagName', fn)это ветка для конкретного_tag. Внутриfnтип уже сужен;Match.exhaustiveфинал. Компилятор проверяет, что все варианты разобраны. Забыл ветку, не скомпилируется.
Альтернативы: Match.when({...}) для матчинга по форме (например, { status: 200 }), Match.orElse(fn) для дефолтной ветки.
ts-pattern остаётся в проекте: для совсем других сценариев, например для DOM-event-ов или для матчинга-без-Effect. Но для tagged-ошибок Effect-ный Match.value идиоматичнее: тег он понимает напрямую, как и catchTag.
Шаг 1 · formatAlert через Match.value
import { Match } from 'effect';
import type { PulseError } from './errors.ts';
export const formatAlert = (error: PulseError): string =>
Match.value(error).pipe(
Match.tag('NetworkError', (err) => `[network] ${err.url}: хост недоступен`),
Match.tag(
'HttpStatusError',
(err) => `[http] ${err.url}: ожидали ${err.expected}, пришло ${err.status}`,
),
Match.tag('BodyContractError', (err) => `[body] ${err.url}: тело не сошлось со схемой`),
Match.tag('TimeoutError', (err) => `[timeout] ${err.url}: не уложился в ${err.timeoutMs} ms`),
Match.tag('ConfigParseError', (err) => `[config] ${err.path}: не парсится`),
Match.tag('StorageError', () => `[storage] запись на диск упала`),
Match.exhaustive,
);
В каждой ветке err уже сужен. У NetworkError есть url и cause, у HttpStatusError есть url, status, expected. Поля, которых в этом классе нет, через точку не достанутся. Это та самая статическая безопасность, ради которой мы возились с Data.TaggedError.
Хвост Match.exhaustive это контракт: либо ты разобрал все шесть классов, либо TS не пропустит. Добавил седьмой класс в errors.ts, пробуешь собрать, ловишь:
Argument of type 'X' is not assignable to parameter of type 'never'.
И идёшь дописывать ветку. Это и есть тот тип-страховщик, который вы видели в 04-ts/04 · Reliability and types.
Шаг 2 · Match.when для матчинга по форме
Иногда ветвиться нужно не по _tag, а по полю:
const describeStatus = Match.type<{ status: number }>().pipe(
Match.when({ status: 200 }, () => 'ok'),
Match.when({ status: 404 }, () => 'not found'),
Match.when({ status: (n) => n >= 500 }, () => 'server error'),
Match.orElse(() => 'unknown'),
);
describeStatus({ status: 503 }); // 'server error'
Match.when({ status: 200 }, ...) сужает по литеральному значению. Match.when({ status: (n) => n >= 500 }, ...) принимает предикат. Это редко нужно для tagged-ошибок (теги достаточно), но удобно для произвольных форм.
Шаг 3 · Сравнение с ts-pattern
То же самое на ts-pattern:
import { match } from 'ts-pattern';
export const formatAlertTsP = (error: PulseError): string =>
match(error)
.with({ _tag: 'NetworkError' }, (e) => `[network] ${e.url}: хост недоступен`)
.with(
{ _tag: 'HttpStatusError' },
(e) => `[http] ${e.url}: ожидали ${e.expected}, пришло ${e.status}`,
)
.with({ _tag: 'BodyContractError' }, (e) => `[body] ${e.url}: тело не сошлось`)
.with({ _tag: 'TimeoutError' }, (e) => `[timeout] ${e.url}: ${e.timeoutMs} ms`)
.with({ _tag: 'ConfigParseError' }, (e) => `[config] ${e.path}: не парсится`)
.with({ _tag: 'StorageError' }, () => `[storage] запись на диск упала`)
.exhaustive();
Семантически идентично. Различия:
Match.tag('Tag', ...)короче, чем.with({ _tag: 'Tag' }, ...). На двух десятках веток это уже заметно.effect/Matchне тащит вторую зависимость. У нас уже естьeffect, и мы её используем для остального, поэтому братьts-patternради одной функции значило бы держать две похожих библиотеки.ts-patternчуть мощнее в матчинге по сложным формам (например, кортежи, регэкспы). Это редко нужно, и в Pulse мы остаёмся наeffect/Match.
Решение для Pulse: effect/Match для tagged-ошибок и для всего, что внутри Effect-кода. ts-pattern остаётся доступным в Pulse как зависимость, но мы её не дёргаем без причины.
Что взять с собой
Match.value(input).pipe(Match.tag('Tag', fn), Match.exhaustive)это чистый матчинг на Effect-стиле, без внешних либ.Match.exhaustiveстроит компайл-тайм проверку, что все теги разобраны. Седьмой класс не пройдёт мимо.Match.when({...}, fn)для матчинга по форме, не по тегу.ts-patternостаётся под рукой, но для tagged-ошибок мы предпочитаем встроенныйMatch.
Раздел 7 · Эффектный матчинг через Effect.matchEffect
Сцена
recordResult(monitor, probe). На вход эффект, который может упасть. На выход эффект MonitorEvent. Логика: успех становится ProbeSuccess, провал ветвится по _tag и становится ProbeFailure с правильным reason. Оба плеча сами эффекты (мы читаем Date.now(), в будущем подложим Clock-сервис), и наружу выходит уже не Result, а Effect.
Идея словами
Семейство Effect.match* это пары “обработчик успеха, обработчик ошибки”. Вариантов несколько:
| Имя | Подпись плеч | Возвращает |
|---|---|---|
Effect.match | чистые (a) => B, (e) => B | Effect<B, never>, оба плеча возвращают значение |
Effect.matchEffect | оба плеча сами Effect | Effect<B, E', R>, можно делать побочные шаги |
Effect.matchCause | по успеху чистая, по Cause плечо | Effect<B, never>, видно Die и Interrupt |
Effect.matchCauseEffect | оба эффектные, по ошибке Cause | Effect<B, E', R>, максимально гибко |
Когда брать что:
- успех чистый, ошибка чистая, обе ветки это значение, бери
match; - нужно что-то записать в журнал, дёрнуть
Clock, обратиться к сервису, бериmatchEffect; - нужно различить
FailиDie, бериmatchCause/matchCauseEffect.
Шаг 1 · recordResult через matchEffect
import { Effect } from 'effect';
import type { MonitorId } from './config.ts';
import type { HttpStatusError, NetworkError, TimeoutError } from './errors.ts';
import type { MonitorEvent, ProbeFailure, ProbeSuccess } from './events.ts';
import type { ProbeResult } from './probe.ts';
export const recordResult = <R>(
monitorId: MonitorId,
probeEffect: Effect.Effect<ProbeResult, NetworkError | HttpStatusError | TimeoutError, R>,
): Effect.Effect<MonitorEvent, never, R> =>
probeEffect.pipe(
Effect.matchEffect({
onFailure: (error) =>
Effect.sync<ProbeFailure>(() => {
const reason: ProbeFailure['reason'] =
error._tag === 'TimeoutError'
? 'timeout'
: error._tag === 'HttpStatusError'
? 'http-status'
: 'network';
return {
_tag: 'ProbeFailure',
monitorId,
url: error.url,
reason,
at: Date.now(),
};
}),
onSuccess: (result) =>
Effect.sync<ProbeSuccess>(() => ({
_tag: 'ProbeSuccess',
monitorId,
url: result.url,
status: result.status,
elapsedMs: result.elapsedMs,
at: Date.now(),
})),
}),
);
Что важно прочитать вслух:
- На входе
Effect<ProbeResult, NetworkError | HttpStatusError | TimeoutError, R>. На выходеEffect<MonitorEvent, never, R>. Канал ошибок очищен (never), потому что обе ветки возвращают значение. - Оба плеча обёрнуты в
Effect.sync(...): дёргаемDate.now(), побочный шаг. На уроке 04 мы заменимDate.now()наClock.currentTimeMillis, иRоживёт. - Внутри
onFailureтипerrorэто размеченное объединение трёх классов. Мы ветвим по_tagруками, безMatch. Так короче, потому что мы строим одно значениеreason, не возвращаем разные формы результата.
Сравни с Effect.match (без Effect-суффикса): он бы потребовал чистых функций в плечах. Поскольку мы дёргаем Date.now(), чистоты у нас нет, и matchEffect тут уместнее.
Шаг 2 · matchCauseEffect для разделения Fail и Die
Иногда в одном месте надо разнести actionable-ошибку и defect.
const safeProbe = probeEffect.pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.gen(function* () {
if (Cause.hasDies(cause)) {
yield* Effect.logError('программный баг в probe', { cause: Cause.pretty(cause) });
return null;
}
yield* Effect.logWarning('сетевая проблема', { cause: Cause.pretty(cause) });
return null;
}),
onSuccess: (result) => Effect.succeed(result),
}),
);
В обработчике onFailure нам пришёл Cause, не E. Дальше мы используем гарды (Cause.hasDies, Cause.hasInterruptsOnly, Cause.hasFails), чтобы выбрать ветку. Это инструмент верхнего уровня, “финального” обработчика, не каждодневного кода.
Шаг 3 · Когда лучше gen без матча
Если у тебя в gen-блоке уже идут шаги, и ты хочешь обработать одну ошибку, не ломая нить, бери catchTag прямо на нужном yield*:
const program = Effect.gen(function* () {
const config = yield* loadConfig.pipe(Effect.catchTag('ConfigParseError', () => Effect.succeed(emptyConfig)));
const data = yield* fetchData(config);
return data;
});
Это та же история про “обработай у вызывающего”, только с инструментом catchTag прямо в позиции yield*. matchEffect берёшь, когда тебе нужны обе ветки сразу (успех и провал) и обе строят значение одного типа.
Что взять с собой
Effect.matchдля чистых плеч.Effect.matchEffectкогда хотя бы одно плечо хочет дёрнуть сервис или сделать побочный шаг.Effect.matchCause/matchCauseEffectкогда надо разделитьFailиDieв одном обработчике.- В обычном
gen-коде чаще всего удобнее точечныйcatchTagрядом с нужнымyield*, не весьmatchсверху.
Раздел 8 · Cause снаружи: достать ошибку и бросить
Сцена
Pulse у нас CLI, и всю программу запускаем через Effect.runPromise. Раньше или позже тот же probe придётся отдать наружу коду, который про Effect ничего не знает: воркеру очереди задач, адаптеру для legacy-скрипта, чужому плагину. Там ждут не Effect, а Promise, который либо резолвится значением, либо режектится исключением нужного класса.
И вот тут вылезает фокус. Effect.runPromise(p) режектит промис, заворачивая твою ошибку в служебный FiberFailure. В try/catch тебе в руки прилетает не NetworkError, а обёртка, у которой внутри напечатано дерево причин. В лог это удобно, бросить дальше нечем: вызывающий ждал свою ошибку, а получил чужую.
Шаг 1 · runPromiseExit, не runPromise
Сравни две стороны:
import { Effect } from 'effect';
import { NetworkError } from './errors.ts';
const program = Effect.fail(new NetworkError({ url: 'https://x', cause: 'ECONNREFUSED' }));
// Плохо: ошибка теряет тип
try {
await Effect.runPromise(program);
} catch (e) {
// e: FiberFailure, не NetworkError. Бросать как есть нечего:
// тот, кто ловит снаружи, ждал NetworkError.
}
// Хорошо: получаем типизированный Exit
const exit = await Effect.runPromiseExit(program);
if (exit._tag === 'Failure') {
// exit.cause: Cause<NetworkError>, тип на месте
}
runPromiseExit это пара к Effect.exit из Раздела 4: тот же Exit<A, E>, только сразу обёрнут в Promise. На границе с миром, который про Effect ничего не знает, она и нужна.
Шаг 2 · Достать ошибку из Cause
Cause это массив причин с тегами Fail, Die, Interrupt. В каждой причине лежит значение. Чтобы достать одно, есть семейство find:
| Функция | Возвращает | Когда успех |
|---|---|---|
Cause.findErrorOption(cause) | Option<E> | в Cause есть Fail |
Cause.findError(cause) | Result<E, Cause<never>> | в Cause есть Fail |
Cause.findDefect(cause) | Result<unknown, Cause<E>> | в Cause есть Die |
Cause.findInterrupt(cause) | Result<Interrupt, Cause<E>> | в Cause есть Interrupt |
Гарды из Раздела 4 (Cause.hasFails, Cause.hasDies) проверяют форму, семейство find достаёт значение. Заметь, что большинство из них отдаёт Result, а не Option: на неудачном поиске в failure лежит исходный Cause, и его сразу видно, не теряя контекст. Когда нужен именно Option, бери findErrorOption. С Option ты уже встречался в уроке 02-cs · 06 Типы и ошибки, здесь мы только пользуемся его методами:
import { Cause, Option } from 'effect';
const failure = Cause.findErrorOption(exit.cause);
// Option<NetworkError>
const error = Option.getOrElse(failure, () => new Error('cause без типизированной ошибки'));
// NetworkError | Error
Шаг 3 · Cause.squash, когда нужна одна ошибка
Если на тебя смотрит try/catch-граница, и ей всё равно, какой ветки пришла ошибка, лишь бы конкретная, бери Cause.squash:
import { Cause } from 'effect';
const primary = Cause.squash(exit.cause); // unknown
Что вернётся, зависит от того, что нашлось в Cause. Приоритет такой:
| Приоритет | Что нашлось | Что вернётся |
|---|---|---|
| 1 | Fail | твоё значение из Effect.fail |
| 2 | Die | значение defect |
| 3 | только Interrupt | обычный Error('All fibers interrupted without error') |
| 4 | ничего | обычный Error('Empty cause') |
Тип всегда unknown, потому что Cause держит что угодно. Снаружи и не нужен точный класс, нужен факт исключения. Именно этой функцией пользуются Effect.runPromise и Effect.runSync, когда решают, что бросить. Она заведомо теряет часть информации: если причин было несколько, наружу выйдет только первая. Когда нужны все, иди по cause.reasons сам либо возьми Cause.prettyErrors(cause).
Шаг 4 · граница с не-Effect-кодом
Сначала о том, где эта граница не проходит. HTTP-сервер Pulse мы поднимем на http-модуле из ядра effect (это урок 12 · CLI и HTTP), и там обработчик остаётся внутри Effect: ошибку держишь на канале E, платформа сама свернёт её в ответ, никакого runPromiseExit и throw руками. То же с CLI-модулем. Пока ты внутри Effect-рантайма, Cause наружу доставать не нужно.
Cause.squash нужен на честной границе с чужим миром: функция, которую зовёт не-Effect-код и ждёт Promise с конкретным исключением на провале. Типичный пример, адаптер probeOrThrow для воркера очереди задач (он зовёт твой async-хендлер и по брошенному исключению решает, ретраить задачу или нет):
import { Cause, Effect, Exit } from 'effect';
import { type ProbeResult, probe } from './probe.ts';
/** Адаптер для не-Effect-мира: либо ProbeResult, либо брошенная типизированная ошибка. */
export async function probeOrThrow(url: string): Promise<ProbeResult> {
const exit = await Effect.runPromiseExit(probe(url));
return Exit.match(exit, {
onSuccess: (result) => result,
onFailure: (cause) => {
console.error(Cause.pretty(cause));
throw Cause.squash(cause);
},
});
}
Cause.pretty(cause) идёт в лог: там видно все причины со стеком. Cause.squash(cause) уходит наверх. Снаружи отлавливают ровно ту NetworkError, которая родилась внутри probe, а не служебную обёртку FiberFailure.
Что забираешь
- На границе с не-Effect-кодом всегда
Effect.runPromiseExit.runPromiseоставь там, где ловить наружу не нужно. Cause.findErrorOption,Cause.findDefect,Cause.findInterruptдостают значение, дальше работаешь как обычно.Cause.squashотдаёт первичную ошибку по приоритетуFail>Die>Interrupt. Наtry/catch-границе чаще всего нужна именно она.
Pulse · вклад этого урока
Файл src/errors.ts доехал до финального вида (шесть классов плюс алиас PulseError), это уже выписано в Разделе 2. В src/probe.ts ничего не меняется (там пока только NetworkError). Появляется новый файл src/matching.ts и тесты к нему. src/index.ts обновляется, чтобы при провале печатать алёрт через formatAlert.
src/matching.ts
import { Effect, Match } from 'effect';
import { type MonitorId } from './config.ts';
import type { HttpStatusError, NetworkError, PulseError, TimeoutError } from './errors.ts';
import type { MonitorEvent, ProbeFailure, ProbeSuccess } from './events.ts';
import { type ProbeResult } from './probe.ts';
export const formatAlert = (error: PulseError): string =>
Match.value(error).pipe(
Match.tag('NetworkError', (err) => `[network] ${err.url}: хост недоступен`),
Match.tag(
'HttpStatusError',
(err) => `[http] ${err.url}: ожидали ${err.expected}, пришло ${err.status}`,
),
Match.tag('BodyContractError', (err) => `[body] ${err.url}: тело не сошлось со схемой`),
Match.tag('TimeoutError', (err) => `[timeout] ${err.url}: не уложился в ${err.timeoutMs} ms`),
Match.tag('ConfigParseError', (err) => `[config] ${err.path}: не парсится`),
Match.tag('StorageError', () => `[storage] запись на диск упала`),
Match.exhaustive,
);
export const recordResult = <R>(
monitorId: MonitorId,
probeEffect: Effect.Effect<ProbeResult, NetworkError | HttpStatusError | TimeoutError, R>,
): Effect.Effect<MonitorEvent, never, R> =>
probeEffect.pipe(
Effect.matchEffect({
onFailure: (error) =>
Effect.sync<ProbeFailure>(() => {
const reason: ProbeFailure['reason'] =
error._tag === 'TimeoutError'
? 'timeout'
: error._tag === 'HttpStatusError'
? 'http-status'
: 'network';
return {
_tag: 'ProbeFailure',
monitorId,
url: error.url,
reason,
at: Date.now(),
};
}),
onSuccess: (result) =>
Effect.sync<ProbeSuccess>(() => ({
_tag: 'ProbeSuccess',
monitorId,
url: result.url,
status: result.status,
elapsedMs: result.elapsedMs,
at: Date.now(),
})),
}),
);
/** Учебная заплатка для §5: Effect.die ловится через Cause.hasDies. */
export const divideOrDie = (a: number, b: number): Effect.Effect<number> =>
b === 0
? Effect.die(new Error('division by zero is a programmer error'))
: Effect.succeed(a / b);
Сигнатуры читаются вслух так:
formatAlert(error: PulseError): string. Чистая функция. НикакогоEffect, никакого побочного шага.recordResult(monitorId, probeEffect): Effect<MonitorEvent, never, R>. Эффектная: на входе твой пробинг, на выходе нормализованное событие. Канал ошибок очищен (never), потому что мы поглотилиNetworkError | HttpStatusError | TimeoutErrorчерезmatchEffect.divideOrDie(a, b): Effect<number>. Учебная: приb === 0уходит в defect, поймать снаружи можно только черезEffect.exitплюсCause.hasDiesили черезsandbox.
test/errors.test.ts
Тесты живут в эталонном проекте, и ты их переносишь в свой pulse-<nick> один в один:
import { Cause, Effect, Schema } from 'effect';
import { describe, expect, it } from 'vitest';
import { MonitorId } from '~/config.ts';
import {
BodyContractError,
ConfigParseError,
HttpStatusError,
NetworkError,
StorageError,
TimeoutError,
} from '~/errors.ts';
import { divideOrDie, formatAlert, recordResult } from '~/matching.ts';
const id = Schema.decodeUnknownSync(MonitorId)('github');
describe('formatAlert', () => {
it('покрывает все шесть вариантов', () => {
expect(formatAlert(new NetworkError({ url: 'a', cause: 'x' }))).toContain('[network]');
expect(formatAlert(new HttpStatusError({ url: 'a', status: 500, expected: 200 }))).toContain(
'[http]',
);
expect(formatAlert(new BodyContractError({ url: 'a', cause: 'x' }))).toContain('[body]');
expect(formatAlert(new TimeoutError({ url: 'a', timeoutMs: 100 }))).toContain('[timeout]');
expect(formatAlert(new ConfigParseError({ path: '/x', cause: 'x' }))).toContain('[config]');
expect(formatAlert(new StorageError({ cause: 'x' }))).toContain('[storage]');
});
});
describe('recordResult', () => {
it('строит ProbeSuccess из успешного probe', async () => {
const ok = Effect.succeed({ url: 'https://a', status: 200, elapsedMs: 10 });
const event = await Effect.runPromise(recordResult(id, ok));
expect(event._tag).toBe('ProbeSuccess');
if (event._tag === 'ProbeSuccess') {
expect(event.status).toBe(200);
}
});
it('строит ProbeFailure с reason="network" из NetworkError', async () => {
const fail = Effect.fail(new NetworkError({ url: 'https://a', cause: 'x' }));
const event = await Effect.runPromise(recordResult(id, fail));
expect(event._tag).toBe('ProbeFailure');
if (event._tag === 'ProbeFailure') {
expect(event.reason).toBe('network');
}
});
it('строит ProbeFailure с reason="timeout" из TimeoutError', async () => {
const fail = Effect.fail(new TimeoutError({ url: 'https://a', timeoutMs: 100 }));
const event = await Effect.runPromise(recordResult(id, fail));
expect(event._tag).toBe('ProbeFailure');
if (event._tag === 'ProbeFailure') {
expect(event.reason).toBe('timeout');
}
});
});
describe('divideOrDie', () => {
it('die на нулевом делителе ловится через Effect.exit + Cause.hasDies', async () => {
const exit = await Effect.runPromise(Effect.exit(divideOrDie(1, 0)));
expect(exit._tag).toBe('Failure');
if (exit._tag === 'Failure') {
expect(Cause.hasDies(exit.cause)).toBe(true);
}
});
});
Запусти pnpm test, получи зелёные галки, иди дальше.
src/index.ts
import { Effect } from 'effect';
import { decodeFromFile } from './config.ts';
import type { PulseError } from './errors.ts';
import { formatAlert, recordResult } from './matching.ts';
import { probe } from './probe.ts';
const program = Effect.gen(function* () {
const config = yield* decodeFromFile('./pulse.config.json');
for (const monitor of config.monitors) {
const event = yield* recordResult(monitor.id, probe(monitor.url));
yield* Effect.sync(() => {
if (event._tag === 'ProbeSuccess') {
console.log(`${event.monitorId}: status ${event.status} in ${event.elapsedMs} ms`);
} else if (event._tag === 'ProbeFailure') {
console.warn(`${event.monitorId}: ${event.reason}`);
}
});
}
});
Effect.runPromise(
program.pipe(
Effect.catch((error: PulseError) =>
Effect.sync(() => {
console.error(formatAlert(error));
process.exit(2);
}),
),
),
);
В program recordResult уже поглотил ошибки пробинга. Поэтому в E остаётся только то, что может упасть до пробинга: decodeFromFile (ConfigParseError). Снаружи мы ловим эту единственную actionable-ошибку через Effect.catch, печатаем алёрт через formatAlert, выходим с кодом 2. Это и есть та дисциплина, ради которой мы говорили “не давай ошибкам бубулькать через пять уровней”.
Финал · чек-лист
Если по любому пункту “не уверен”, вернись в Раздел 1 и спокойно пройди по тексту ещё раз. Это самый плотный участок серии 25-effect, на нём держатся уроки 04 (сервисы и ошибки в Layer-ах), 06 (Fiber.interrupt через Cause.isInterrupted), 11 (Schedule с retry-политикой по тегу).
ДЗ
Все четыре задания делаются в твоём репозитории pulse-<nick>. После каждого открой PR с тегом lesson-03, ментор посмотрит и оставит комментарии.
Дальше
Следующий урок · 04. Сервисы и Layer. После этого урока параметр R перестанет быть пустым never: появятся HttpService, Storage, Logger, Clock, и наш recordResult перестанет руками дёргать Date.now().
Параллельно полезно перечитать:
- 04-ts · 04 Reliability and types, про parse-don’t-validate и про “ошибки в типе” на zod. То же мышление, другая библиотека.
- 02-cs · 08 Системы эффектов, про то, почему канал
Eэто часть системы эффектов, а не “удобный синтаксис”.