Effect intro: Effect<A, E, R>, gen, runtime, первый probe
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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 будет расти
- Этот урок. Одна функция
probe(url), которая ходит по адресу и возвращает статус. Запуск черезEffect.runPromise. Сорок строк кода, никакого CLI, никакого сервера. - Уроки 02-03. Конфиг
pulse.config.jsonчерезSchema. Типизированные ошибкиNetworkError,HttpStatusError,TimeoutError. - Уроки 04-05. Появляются компоненты программы:
HttpService,Storage,Logger. Корректное закрытие файлов и соединений поCtrl+C. - Уроки 06-07. Параллельная проверка нескольких адресов сразу. Очередь задач (
Queue), шина событий (PubSub), лимит запросов на домен (Semaphore). - Уроки 08-09. Атомарный счётчик подряд идущих провалов на транзакциях (
TxRefплюсEffect.tx). Поток событий черезStreamс агрегацией по окнам. - Уроки 10-11. Батчинг DNS через
RequestResolver. Расписание черезSchedule.exponentialс jitter, таймауты, race с резервным адресом. - Урок 12. Обе команды собираются:
pulse monitor,pulse watch,pulse serve. HTTP-сервер с/status,/api/monitors,POST /api/monitors, SSE/events. - Урок 13. Тесты через
@effect/vitestсTestClock(пять минут симулируются за 0мс),HttpRouter.toWebHandlerдля HTTP-handler-ов, рефактор по code-style guidelines.
Заведи себе репозиторий прямо сейчас, имя pulse-<твой-nick>. После каждого урока ты открываешь в нём PR с тегом lesson-NN, чтобы к нему можно было оставлять комментарии. К концу этого урока в репозитории появятся два первых файла.
Что будет в уроке
- Раздел 1, Promise как контр-пример. Три проблемы: немедленный старт, ошибки в
unknown, окружение в воздухе. - Раздел 2, тип
Effect<A, E, R>. Сигнатура и виджет, на котором видно, как интерпретатор обходитyield*. - Раздел 3,
Effect.genкак do-нотация. Перевод лестницы из вложенныхflatMapв линейный код, мост наio-monad. - Раздел 4, конструкторы. Пять способов сказать “это рецепт”.
- Раздел 5, runtime. Три точки входа, и сценарий, в котором одна из них падает с
AsyncFiberError. - 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 это его прямое продолжение в глубину.