Раздел 25 · Effect-TS

Effect intro: Effect<A, E, R>, gen, runtime, первый probe

middle~80 мин

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

Effect intro: Effect<A, E, R>, gen, runtime, первый probe

Сцена · рецепт пирога

Открой блокнот, запиши “взять муку, разбить три яйца, поставить в духовку на 180”. Это рецепт. Сам по себе он ничего не печёт, печёт духовка. Пока рецепт лежит на бумаге, кухня тихая, мука в шкафу.

Effect, это рецепт. Runtime, это духовка. Пока у тебя в руках значение типа Effect, ничего не происходит. Программа не делает запрос в сеть, не пишет в файл, не падает с ошибкой. Она лежит в переменной как обычные данные. Запуск, это отдельный, явный шаг.

Это та же архитектура, которую ты собрал руками в воркшопе io-monad. Там был свой IO, свой bind, свой интерпретатор. Здесь библиотека всё то же самое отдаёт готовым, плюс ошибки, плюс окружение, плюс конкурентность. В этом уроке разбираем сам тип Effect и три способа его запустить.

Сквозной проект · Pulse

Чтобы новые понятия не висели в воздухе, через все тринадцать уроков мы вместе собираем одну программу. Зовут её Pulse, и она про то, как программно следить за тем, что у тебя в интернете живо, а что упало.

Сценарий

У тебя есть несколько адресов в сети: твой собственный лендинг, API для мобильного приложения, чужой сервис, на который ты завязан, статус-страница облачного провайдера. Хочется один раз сказать “ходи на github.com каждые тридцать секунд, на example.com каждую минуту, и сообщай мне, если что-то упало”, и забыть.

Pulse делает ровно это. Ты даёшь ей список адресов с интервалами, она по расписанию шлёт туда HTTP-запросы, записывает что получила в ответ, считает время отклика и сколько раз подряд адрес упал. На результат можно посмотреть двумя способами.

Глазами в терминале. Команда pulse monitor add https://github.com --interval 30s добавляет адрес в список. Команда pulse watch рисует живую таблицу с цветами: адрес, последний статус, миллисекунды отклика, сколько раз подряд упал. Нажал Ctrl+C, программа корректно вышла, ни одного зависшего сокета.

По HTTP. Команда pulse serve --port 8080 поднимает на твоей машине маленький веб-сервер. curl http://localhost:8080/status отдаёт ту же таблицу в JSON. Подключаешься из браузера к http://localhost:8080/events через EventSource, и на тебя польётся живой поток событий: “github.com ответил 200 за 142 ms”, “example.com упал по таймауту”. К такому интерфейсу можно подцепить дашборд или Slack-бота.

Почему именно Pulse

Это удобная цель для серии. На ней за тринадцать уроков всплывают почти все темы Effect: ленивые описательные программы (план запросов отдельно, выполнение отдельно), типизированные ошибки (недоступный хост и таймаут это разные ситуации и разные действия), сервисы и Layer (fetch и запись в файл хочется подменять в тестах), файберы (десять адресов лучше проверять одновременно), транзакции (счётчик “три провала подряд” должен быть согласованным между всеми компонентами), Stream (лог событий приятно читать как поток с агрегацией по окнам), Schedule (повторять каждые тридцать секунд с экспонентой при ошибке это и есть базовая задача расписания), HTTP-сервер на Effect.

И главное. Обе команды, pulse watch и pulse serve, под капотом запускают одну и ту же программу. Запросы шлёт один модуль, события рассылает один PubSub, в файл пишет один Storage, счётчик провалов держит один TxRef. Различаются эти команды только тем, как показывают результат: одна ANSI-таблицей в терминал, вторая JSON-ом и SSE по HTTP. В Effect такая общая часть называется MainLive Layer, мы соберём её в уроке 04 и будем достраивать до конца серии.

Когда в Разделе 2 появится сигнатура Effect<A, E, R>, параметр R это и есть содержимое MainLive. Pulse покажет на живом примере, ради чего параметр R нужен: пишешь логику один раз, подаёшь живые реализации в проде и заглушки в тестах через Effect.provide, разные точки входа (терминал и HTTP) делят один граф зависимостей.

Картинка финала, к которому мы идём:

                       ┌─────────────────────────────────────────┐
                       │              MainLive Layer             │
                       │                                         │
  pulse watch  ───────►│  HttpService · Storage · Logger · Clock │◄───── pulse serve
  pulse monitor add    │  MonitorEvents · SlaState (TxRef)       │       GET  /status
  pulse monitor list   │  Probe · Schedule · BatchedDnsResolver  │       GET  /api/monitors
  pulse init           │                                         │       POST /api/monitors
                       └─────────────────────────────────────────┘       GET  /events  (SSE)
                                            ▲
                                            │
                               ┌────────────┴────────────┐
                               │  один Runtime, один     │
                               │  граф зависимостей,     │
                               │  две точки входа        │
                               └─────────────────────────┘

Как Pulse будет расти

  1. Этот урок. Одна функция probe(url), которая ходит по адресу и возвращает статус. Запуск через Effect.runPromise. Сорок строк кода, никакого CLI, никакого сервера.
  2. Уроки 02-03. Конфиг pulse.config.json через Schema. Типизированные ошибки NetworkError, HttpStatusError, TimeoutError.
  3. Уроки 04-05. Появляются компоненты программы: HttpService, Storage, Logger. Корректное закрытие файлов и соединений по Ctrl+C.
  4. Уроки 06-07. Параллельная проверка нескольких адресов сразу. Очередь задач (Queue), шина событий (PubSub), лимит запросов на домен (Semaphore).
  5. Уроки 08-09. Атомарный счётчик подряд идущих провалов на транзакциях (TxRef плюс Effect.tx). Поток событий через Stream с агрегацией по окнам.
  6. Уроки 10-11. Батчинг DNS через RequestResolver. Расписание через Schedule.exponential с jitter, таймауты, race с резервным адресом.
  7. Урок 12. Обе команды собираются: pulse monitor, pulse watch, pulse serve. HTTP-сервер с /status, /api/monitors, POST /api/monitors, SSE /events.
  8. Урок 13. Тесты через @effect/vitest с TestClock (пять минут симулируются за 0мс), HttpRouter.toWebHandler для HTTP-handler-ов, рефактор по code-style guidelines.

Заведи себе репозиторий прямо сейчас, имя pulse-<твой-nick>. После каждого урока ты открываешь в нём PR с тегом lesson-NN, чтобы к нему можно было оставлять комментарии. К концу этого урока в репозитории появятся два первых файла.

Что будет в уроке

  1. Раздел 1, Promise как контр-пример. Три проблемы: немедленный старт, ошибки в unknown, окружение в воздухе.
  2. Раздел 2, тип Effect<A, E, R>. Сигнатура и виджет, на котором видно, как интерпретатор обходит yield*.
  3. Раздел 3, Effect.gen как do-нотация. Перевод лестницы из вложенных flatMap в линейный код, мост на io-monad.
  4. Раздел 4, конструкторы. Пять способов сказать “это рецепт”.
  5. Раздел 5, runtime. Три точки входа, и сценарий, в котором одна из них падает с AsyncFiberError.
  6. Pulse · вклад этого урока. Первые три файла твоего pulse-<nick>: src/errors.ts (семейство tagged-ошибок начинается с одного класса), src/probe.ts (одна функция, читай сигнатуру вслух) и src/index.ts (однострочный запуск через runPromise).

Раздел 1 · Promise как контр-пример

Сцена

Ты вызвал fetch(url), и поезд уже поехал. Никакой паузы, никакой команды “поехали”. Сетевой запрос полетел в момент создания значения. Хочешь отменить, ищи AbortController, прокидывай его пять уровней вниз. Хочешь повторить с экспонентой, пиши цикл руками. Хочешь подменить fetch в тестах, ставь vi.mock. Это и есть жизнь без явной ленивости.

Идея словами

Promise это немедленный контейнер: он начинает работу в момент создания. У него один тип успеха T, ошибка приходит в catch как unknown. Контракта по окружению нет: fetch приклеен к платформе, а не к параметру.

Эти три недостатка не убираются обёрткой. Их видно в типе. И именно от них Effect лечит, а не от “у меня случайно сломался конвейер обработки”.

Шаг 1 · Немедленный старт

const a = fetch('/api/users/1'); // запрос #1 уже в полёте
const b = fetch('/api/users/2'); // запрос #2 уже в полёте

// если выяснилось, что не тот пользователь, оба запроса
// уже сожгли трафик. чтобы их остановить, надо было заранее
// завести AbortController на каждый и прокинуть в каждый вызов.
// явной паузы между "описал" и "запустил" в Promise нет.

Создание Promise это побочный эффект. Каждый раз, когда ты “просто описываешь, что делать”, ты на самом деле уже это делаешь.

Шаг 2 · Ошибка в unknown

async function getUser(id: string) {
  try {
    const r = await fetch(`/users/${id}`);
    return await r.json();
  } catch (e) {
    // e: any. что это? сеть? парсинг? отмена? программная ошибка?
  }
}

В типе функции ошибки нет. Любой try/catch ловит всё подряд, и решать, что именно прилетело, тебе предстоит руками. На больших сервисах это превращается в вечную игру в “угадай”, потому что ошибки разных уровней неотличимы.

Шаг 3 · Окружение в воздухе

async function getUser(id: string) {
  // откуда берётся fetch? откуда берётся console? откуда берётся текущее время?
  // ниоткуда. они приклеены к платформе.
  // подменить в тестах только через vi.mock или внешний контейнер зависимостей.
}

Зависимости функции скрыты. По сигнатуре ты не видишь, нужны ли ей сеть, БД, логгер. На код-ревью это превращается в “пойди прочитай тело функции, чтобы понять, что она трогает”.

Что взять с собой

Promise хорош для одиночного асинхронного шага без конкурентности и внедрения зависимостей. На большой системе три его дыры (немедленный старт, необъявленная ошибка, неявное окружение) дают столько боли, что разработчики придумывают neverthrow, fp-ts, effect. Каждый закрывает дыры по-своему. Effect закрывает все три одной сигнатурой.

Раздел 2 · Тип Effect<A, E, R>

Сцена

Один тип. Три истории: что вернёт в случае успеха, как может упасть, что ему нужно для запуска. Любой Effect-код читается по этим трём ячейкам. Если ты выработал привычку смотреть на Effect<A, E, R> сразу, ты уже понимаешь 80 процентов незнакомого кода.

Идея словами

import { Effect } from 'effect';

declare const probe: Effect.Effect<Response, NetworkError, never>;
//                                  ↑           ↑              ↑
//                                  A           E              R
//                                успех       ошибка        окружение
  • A, тип значения при успехе. То, что окажется в await Effect.runPromise(probe).
  • E, тип ожидаемой ошибки. Не unknown, а конкретное размеченное объединение. На каждый кейс провала отдельный класс с понятным _tag.
  • R, окружение. Какие сервисы должны быть подложены, чтобы программа смогла запуститься. never означает “ничего не нужно”, всё остальное приходит как явное требование. В этом уроке R = never, в уроке 04 туда положим HttpService, Storage, Logger.

И самое важное. Effect ленивый. Создание значения ничего не запускает. Внутри probe лежит описание шагов, не результаты их выполнения.

Виджет · интерпретатор обходит yield

Ниже маленькая модель того, что такое “интерпретатор обходит yield*”. Слева код, справа окно интерпретатора. Жми yield* ->, переключай сценарий. Смотри, как меняется A после очередного связывания, как накапливается объединение в E, и как в логе появляется новая запись с очередным шагом.

Что на экране:

  • слева код урока с курсором на текущем yield*;
  • справа три ячейки, A (что сейчас в локальной переменной), E (накопленное объединение возможных ошибок), и лог пройденных шагов;
  • красная рамка шага значит, что шаг упал, цепочка коротко замкнулась, A дальше не наполняется.

Через минуту-две у тебя в голове осядет картинка: gen это конечный автомат, который на каждом yield* отдаёт интерпретатору одно описание. Интерпретатор либо подставляет полученное значение обратно в генератор, либо коротко замыкает цепочку через канал E. Дальше эта картинка работает на любой Effect.gen-программе.

Шаг 1 · Чтение по A

A фиксируется в самом конце, на return. До этого момента в генераторе через связывания проходят другие типы: void от Effect.log, Response от обёртки над fetch, string от чтения тела ответа. На уровне общей сигнатуры важна только последняя точка выхода.

declare const httpGet: (url: string) => Effect.Effect<Response, NetworkError>;
declare const httpText: (r: Response) => Effect.Effect<string, ParseError>;

const probe = (url: string) =>
  Effect.gen(function* () {
    const response = yield* httpGet(url);   // тут A = Response
    const body = yield* httpText(response); // тут A = string
    return body;                             // итоговый A = string
  });
// probe: (url: string) => Effect<string, NetworkError | ParseError, never>

httpGet и httpText это вспомогательные функции вокруг встроенного fetch. Реализацию httpGet ты увидишь в src/probe.ts ниже в этом уроке.

Шаг 2 · Чтение по E

E это объединение всех типов, которые могут прилететь в канал ошибки. Каждый yield* добавляет свой кусок. Если ничего не падает, остаётся never.

import { Data } from 'effect';

class NetworkError extends Data.TaggedError('NetworkError')<{ cause: unknown }> {}
class ParseError extends Data.TaggedError('ParseError')<{ cause: unknown }> {}

declare const probe: (
  url: string,
) => Effect.Effect<unknown, NetworkError | ParseError, never>;

В E сидят два размеченных класса. Ниже по урокам мы научимся ловить их по _tag через catchTag, маршрутизировать через match, переводить в непредвиденные ошибки через orDie. Пока главное, что они видны в типе, и вызывающая сторона не может их пропустить.

Шаг 3 · Чтение по R

R это набор сервисов, без которых программа не запустится. Effect не даст соврать: пока в R лежит хоть один сервис, runPromise откажется компилироваться, пока ты не подложишь слой.

declare const probe: Effect.Effect<Response, NetworkError, HttpService>;

Effect.runPromise(probe);
//      ^^^^^^^^^^ ошибка типа: HttpService не покрыт

В этом уроке мы держим R = never. Все шаги используют только глобальный fetch. В уроке 04 спрячем fetch за HttpService, и в R появится первый житель.

Что взять с собой

  • На любом куске Effect-кода первое, что ты делаешь, это отвечаешь на три вопроса: что в A, что в E, что в R.
  • A это итог return. E это объединение всех возможных провалов по дороге. R это контракт по сервисам.
  • Если в R сидит хоть один сервис, программа без явного provide не запустится. Это и есть функциональное внедрение зависимостей на типах.

Раздел 3 · Effect.gen как do-нотация

Сцена

Композиция через flatMap быстро превращается в лестницу из вложенных вызовов на типах. Это работает, читать тяжело. Решение, которое предлагает Effect, генератор как do-нотация. Снаружи похоже на async/await, внутри это та самая doIO, которую ты собрал руками в examples/our-io/src/do/symbol-iterator.ts.

Идея словами

Effect.gen(function* () { ... }) принимает обычный JS-генератор. Внутри ты пишешь императивный с виду код. Каждый yield* effect распаковывает значение, связывает его с локальной переменной, и идёт дальше. Если effect упал, yield* коротко замыкает цепочку, и значение E улетает наверх.

Это сахар поверх flatMap. Семантика та же. Читать в десять раз приятнее, особенно когда шагов больше трёх.

Шаг 1 · Лестница из flatMap

import { Effect } from 'effect';

const tooMuch = (url: string) =>
  Effect.flatMap(Effect.sync(() => Date.now()), (startedAt) =>
    Effect.flatMap(httpGet(url), (response) =>
      Effect.flatMap(Effect.sync(() => Date.now() - startedAt), (elapsedMs) =>
        Effect.succeed({ url, status: response.status, elapsedMs }),
      ),
    ),
  );

Три уровня вложенности на три действия. Промахнулся скобкой, и тип у тебя Effect<Effect<Effect<...>>>, который никто не звал. Стиль тоже: вместо линейного “сначала это, потом то” получается ёлочка справа.

Шаг 2 · Тот же код через gen

const program = (url: string) =>
  Effect.gen(function* () {
    const startedAt = yield* Effect.sync(() => Date.now());
    const response = yield* httpGet(url);
    const elapsedMs = yield* Effect.sync(() => Date.now() - startedAt);
    return { url, status: response.status, elapsedMs };
  });

Линейно сверху вниз, как async/await. Скобки на месте, связывания читаются как обычные const. Тип у program тот же самый, что у tooMuch, никакой магии при исполнении нет.

Шаг 3 · yield*, а не yield

Обрати внимание на звёздочку. Это делегирующий yield*: TS не теряет тип, потому что Effect сам становится Iterable<YieldWrap<Effect<A, E, R>>>, и yield* достаёт из него ровно одно значение. Если поставишь yield без звёздочки, получишь невнятный unknown и компилятор будет ругаться.

// ✓ правильно
const x = yield* Effect.succeed(1);

// ✗ так не работает, звёздочки нет, тип развалится
const x = yield Effect.succeed(1);

Под капотом ровно тот же приём, который ты собрал в воркшопе io-monad, часть 8.4. Effect реализует Symbol.iterator на своём инстансе, генератор Effect.gen тянет шаги через next(), обёртка YieldWrap помогает TS правильно вывести типы.

Что взять с собой

  • Effect.gen это do-нотация на генераторах. Линейный код со связываниями, без лестницы.
  • yield* распаковывает Effect, связывает результат, продолжает. На первом упавшем шаге цепочка коротко замыкается.
  • Семантика идентична flatMap. Под капотом всё тот же Symbol.iterator-трюк, который ты уже разбирал на io-monad.

Раздел 4 · Конструкторы

Сцена

Чтобы построить Effect, ты не звонишь в сеть и не пишешь в файл руками. Ты выбираешь конструктор, который описывает, что должно случиться при запуске. Пять штук покрывают первый месяц с Effect.

Идея словами

КонструкторСигнатураКогда брать
Effect.succeed(a)Effect<A, never, never>готовое значение, без шагов
Effect.fail(e)Effect<never, E, never>готовая ошибка, без шагов
Effect.sync(() => a)Effect<A, never, never>синхронный код, который точно не падает
Effect.try({ try, catch })Effect<A, E, never>синхронный код, который может упасть
Effect.tryPromise({ try, catch })Effect<A, E, never>обёртка над API, который возвращает Promise

Принцип такой: если код может упасть, выбираешь вариант с конвертацией ошибки. Голый try без catch запрещён правилами курса, потому что без catch ошибка падает в непредвиденную ошибку, а такая ошибка уже не часть E, и её сложнее заметить.

Шаг 1 · Готовые значения

import { Data, Effect } from 'effect';

const ok = Effect.succeed(42);
// ok: Effect<number, never, never>

class Boom extends Data.TaggedError('Boom')<{ reason: string }> {}
const bad = Effect.fail(new Boom({ reason: 'demo' }));
// bad: Effect<never, Boom, never>

Это самые мелкие кирпичи. Удобны как заглушки в тестах и как ветки в match.

Шаг 2 · Синхронный код

const now = Effect.sync(() => Date.now());
// now: Effect<number, never, never>

const parsed = Effect.try({
  try: () => JSON.parse(input),
  catch: (cause) => new ParseError({ cause }),
});
// parsed: Effect<unknown, ParseError, never>

sync для кода, который вообще не может упасть (даже JSON.stringify не может, если только не дал ему циклическую структуру). try для всего остального синхронного. Поле catch обязательно.

Шаг 3 · API, который возвращает Promise

const fetched = Effect.tryPromise({
  try: () => fetch('https://example.com'),
  catch: (cause) => new NetworkError({ cause }),
});
// fetched: Effect<Response, NetworkError, never>

Это твой основной инструмент для оборачивания любого SDK, который возвращает Promise. Поле try это ленивая функция, она не вызывается, пока программу не запустят. Поле catch это конвертация неизвестной ошибки в твой размеченный класс.

Никакого try { ... } catch (e: any) в Effect-коде. Если ты хочешь поймать промах, ты делаешь это через типизированный класс, и точка.

Что взять с собой

  • succeed/fail, готовые значения и готовые ошибки.
  • sync, для синхронного кода без падений.
  • try/tryPromise, обёртки с обязательным catch в твой размеченный класс.
  • try { ... } catch (e: any) в Effect-коде запрещён. Ошибка должна попадать в E через _tag, а не в unknown.

Раздел 5 · Runtime · три точки входа

Сцена

Ты собрал программу. Теперь надо её исполнить. У Effect есть три точки входа: runPromise для асинхронного результата, runFork для запуска без ожидания результата с управлением жизненным циклом, и runSync для целиком синхронных программ. Выбор зависит от того, есть ли в программе асинхронные шаги, и нужен ли тебе результат прямо сейчас.

Идея словами

ЗапускВозвращаетЧто делает
Effect.runPromise(p)Promise<A>нормальное завершение через await, ошибка попадает в .catch
Effect.runPromiseExit(p)Promise<Exit<A, E>>то же, но ошибка остаётся в Exit, а не превращается в throw
Effect.runFork(p)RuntimeFiber<A, E>запуск без ожидания результата, можно прервать через Fiber.interrupt
Effect.runSync(p)Aсинхронный запуск; падает с AsyncFiberError, если внутри встретился асинхронный шаг

Правило: если внутри программы есть хоть один tryPromise, delay, timeout, sleep, ты обязан запускать через runPromise или runFork. runSync упадёт.

Шаг 1 · runPromise

const result = await Effect.runPromise(probe('https://example.com'));
console.log(result.status); // 200

Самый частый сценарий. Ловишь ошибку через обычный try/catch или .catch(...). Минус: ошибка прилетает уже как Cause-завёрнутый объект, его неудобно разбирать руками. На практике мы обработаем её через Effect.catchTag ещё до запуска, и в runPromise уже падать будет нечем.

Шаг 2 · runFork

import { Effect, Fiber } from 'effect';

const fiber = Effect.runFork(probe('https://example.com'));

// где-то ниже, например по SIGTERM:
process.on('SIGTERM', () => Effect.runFork(Fiber.interrupt(fiber)));

Запустил и отпустил, с возможностью прервать. На SIGTERM посылаешь interrupt, все связанные файберы корректно останавливаются. Это и есть структурная конкурентность, к которой мы вернёмся в уроке 06.

Шаг 3 · runSync, и куда он бьёт

const ok = Effect.runSync(Effect.succeed(42));    // 42
const ok2 = Effect.runSync(Effect.sync(() => 7)); // 7

// gen из чисто синхронных шагов тоже работает:
const sum = Effect.runSync(
  Effect.gen(function* () {
    const a = yield* Effect.succeed(2);
    const b = yield* Effect.sync(() => 3);
    return a + b;
  }),
);
// sum = 5

const bad = Effect.runSync(probe('https://example.com'));
// упадёт с AsyncFiberError: Effect.tryPromise это асинхронный шаг

runSync уместен ровно тогда, когда программа целиком собрана из succeed/fail/sync/try. Любой tryPromise, любая задержка, любой файбер, и runSync упадёт. Это полезно: компилятор не видит асинхронность, runtime её ловит и громко говорит “ты ошибся”.

Что взять с собой

  • runPromise это дефолт. runFork для запуска без ожидания результата с управлением жизненным циклом. runSync для целиком синхронных программ.
  • Любой tryPromise или sleep делает программу асинхронной, runSync на ней упадёт.
  • В реальном CLI обычно сочетается так: точка входа делает runFork, на SIGTERM посылает interrupt, итог считывается через Fiber.await.

Pulse · вклад этого урока

Время заложить первый камень. Помнишь архитектуру из раздела “Сквозной проект · Pulse” в начале урока? Там было два файла поверхности (CLI и HTTP-сервер) и шесть сервисов внутри MainLive. Сегодня у нас нет ни одного из них. Сегодня у нас три файла, одна функция и одна точка запуска через runPromise. Никаких сервисов (04 · Сервисы и Layer), никакого расписания (11 · Runtime и Schedule), никакого CLI-фреймворка и сервера (12 · CLI и Terminal). Ровно тот минимум, на котором тип Effect<A, E, R> уже виден целиком.

src/errors.ts

import { Data } from 'effect';

export class NetworkError extends Data.TaggedError('NetworkError')<{
  readonly url: string;
  readonly cause: unknown;
}> {}

Один файл, один класс, размеченный по _tag. В уроке 02 · Schema здесь появится ConfigParseError, в уроке 03 · Tagged-ошибки ещё четыре (HttpStatusError, BodyContractError, TimeoutError, StorageError), и весь канал ошибок Pulse будет лежать в одном месте.

src/probe.ts

import { Effect } from 'effect';

import { NetworkError } from './errors.ts';

export type ProbeResult = {
  readonly url: string;
  readonly status: number;
  readonly elapsedMs: number;
};

export const probe = (url: string): Effect.Effect<ProbeResult, NetworkError> =>
  Effect.gen(function* () {
    const startedAt = yield* Effect.sync(() => Date.now());
    const response = yield* Effect.tryPromise({
      try: (signal) => fetch(url, { signal }),
      catch: (cause) => new NetworkError({ url, cause }),
    });
    const elapsedMs = yield* Effect.sync(() => Date.now() - startedAt);
    return { url, status: response.status, elapsedMs };
  });

Прочитай сигнатуру вслух. “probe берёт url, возвращает Effect, который при успехе отдаст ProbeResult, при ожидаемой ошибке отдаст NetworkError, и для запуска ему ничего не нужно”. Это и есть тот навык чтения, ради которого мы прошли Раздел 2.

Обрати внимание на signal в tryPromise. Effect передаёт сюда AbortSignal, привязанный к текущему файберу. Когда файбер прерывают (Fiber.interrupt, таймаут, Ctrl+C), fetch ловит сигнал и сам отменяет сетевой запрос. Без этого Promise от fetch продолжал бы висеть, и ты получил бы ровно ту самую “немедленную” болезнь из Раздела 1.

src/index.ts

import { Effect } from 'effect';
import { probe } from './probe.ts';

const args = process.argv.slice(2);
const url = args[1];

if (args[0] !== 'url' || url === undefined) {
  console.error('usage: pnpm start url <https://...>');
  process.exit(1);
}

const program = Effect.gen(function* () {
  const result = yield* probe(url);
  yield* Effect.sync(() => {
    console.log(`status ${result.status} in ${result.elapsedMs} ms`);
  });
});

Effect.runPromise(program).catch((cause) => {
  console.error('probe failed:', cause);
  process.exit(2);
});

Команда pnpm start url https://example.com печатает status 200 in 142 ms. Если хост недоступен, runPromise упадёт, в catch прилетит NetworkError. Никакой защиты от мусорных аргументов, кроме базовой проверки. В уроке 02 · Schema заменим её на разбор через Schema, в уроке 12 · CLI и Terminal на CLI-модуль из ядра effect (effect/unstable/cli).

Финал · чек-лист

Чек-листготово

Если по любому пункту “не уверен”, вернись к Разделу 2 и поиграй с виджетом ещё раз. Это самый важный камень серии. На нём стоят следующие 12 уроков.

ДЗ

Все три задания делаются в твоём репозитории pulse-<nick>. После каждого открой PR с тегом lesson-01, ментор посмотрит и оставит комментарии.

Дальше

Следующий урок · 02. Schema, branded, parse-don’t-validate. Там в Pulse въезжает декларативный парсинг конфига, появляются Url, Interval, MonitorId, и мы перестаём руками проверять, что пришло снаружи.

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

  • 02-cs · 08 Системы эффектов, теоретическая база. Там IO это значение, runtime это интерпретатор, всё ровно то же самое, только без библиотеки.
  • 02-cs · io-monad, воркшоп, где ты руками собирал Symbol.iterator-do, на котором стоит Effect.gen. После этого урока возвращайся туда, и часть 8.4 будет читаться как роман, а не как ребус.
  • 04-ts · 05 Effect-TS на практике, обзорный урок. Серия 25-effect это его прямое продолжение в глубину.