Раздел 25 · Effect-TS

Tagged-ошибки: actionable vs unactionable, catchTag, Cause, matchEffect

middle~140 мин

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

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)любую ошибку из Enever, канал ошибок очищен (если не вернёшь новую)

Имя 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
Diedefect, 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.orDieEffect<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) => BEffect<B, never>, оба плеча возвращают значение
Effect.matchEffectоба плеча сами EffectEffect<B, E', R>, можно делать побочные шаги
Effect.matchCauseпо успеху чистая, по Cause плечоEffect<B, never>, видно Die и Interrupt
Effect.matchCauseEffectоба эффектные, по ошибке CauseEffect<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. Приоритет такой:

ПриоритетЧто нашлосьЧто вернётся
1Failтвоё значение из Effect.fail
2Dieзначение 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().

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