Effect-TS на практике
Финал серии. После урока 04 у тебя есть Result<T, E>, branded-типы, ROP-конвейер через neverthrow. Это покрывает 80 процентов реальных случаев. Оставшиеся 20 процентов, внедрение зависимостей, отмена, сложные политики повторов, наблюдаемость, структурная многозадачность, закрывает Effect-TS.
Effect-TS это “стандартная библиотека функционального TypeScript”, как библиотека сама себя называет. Идея простая: эффект это значение, а не побочное действие. Программа описывает, что должно произойти; среда выполнения интерпретирует. Это та же архитектура, что в io-monad (одна из воркшоп-программ BatSchool), только с готовой production-grade библиотекой.
В этом уроке: одна центральная сигнатура Effect<A, E, R>, генератор как do-нотация, ключевые комбинаторы, Layer и Service для внедрения зависимостей, сквозной weather-CLI с повторами, таймаутом и резервным путём, поэтапное внедрение. И ответ на вопрос “когда брать Effect, а когда оставаться на neverthrow”.
Теоретическая база лежит в уроках 02-cs/07 (классы типов) и 02-cs/08 (системы эффектов). После этого урока воркшопы fp-ts-car-valuation, io-monad, orm-workshop читаются без головной боли.
1. Effect<A, E, R>
Базовая сигнатура.
import { Effect } from 'effect';
declare const fetchWeather: Effect.Effect<Weather, FetchError, HttpService>;
// ↑ ↑ ↑
// успех ошибка окружение
Три параметра.
Aэто тип успеха. Что вернётся, если всё хорошо.Eэто тип ошибки. Что вернётся в случае неудачи. Тип, а не “unknownвcatch”, как уPromise.Rэто окружение (требования). Какие сервисы нужны для выполнения. Получаются через внедрение зависимостей; еслиR = never, эффект исполняется без зависимостей.
Сравнение с другими типами TypeScript:
| Тип | A | E | R |
|---|---|---|---|
Promise<T> | T | unknown (catch) | неявно |
Result<T, E> | T | E | неявно |
ResultAsync<T, E> | T | E | неявно |
Effect<A, E, R> | A | E | явно |
Главное преимущество, окружение явное. Можно собрать программу без рантайм-сервисов, потом проверить тип, потом подставить конкретные реализации (Layer). Это “функциональное внедрение зависимостей”.
Эффект ленивый. Создание fetchWeather ничего не делает. Среда выполнения начинает работу только когда ты явно запускаешь.
import { Effect, Runtime } from 'effect';
const program = Effect.succeed(42);
// program это значение типа Effect<number, never, never>
Effect.runPromise(program).then(console.log); // 42
Это в корне отличается от Promise, который начинает выполнение в момент создания.
2. Генератор как do-нотация: yield* как await
Чистая функциональная композиция через Effect.flatMap быстро вырастает в “лестницу из колбэков на типах”.
import { Effect } from 'effect';
const tooMuch = Effect.flatMap(getCity('moscow'), (city) =>
Effect.flatMap(fetchWeather(city.id), (weather) =>
Effect.flatMap(saveToDb(weather), (saved) =>
Effect.succeed({ city: city.name, temp: saved.temp }),
),
),
);
Нечитаемо. Effect предлагает do-нотацию через генератор.
const program = Effect.gen(function* () {
const city = yield* getCity('moscow');
const weather = yield* fetchWeather(city.id);
const saved = yield* saveToDb(weather);
return { city: city.name, temp: saved.temp };
});
yield* это “распакуй эффект, возьми значение, продолжай дальше”. Полный аналог await для Promise, только над Effect. И ошибки тут же типизированы: если на любом шаге yield* упадёт, цепочка коротко замыкается, и в program: Effect<A, E, R> ошибка попадает в E.
3. Комбинаторы
Минимальный набор, который покроет 90 процентов реальных задач.
pipe. Главный инструмент композиции. Принимает значение слева и применяет функции справа.
import { Effect, pipe } from 'effect';
const program = pipe(
fetchWeather('msk'),
Effect.map((w) => w.temp),
Effect.tap((t) => Effect.sync(() => console.log('temp:', t))),
);
Effect.map трансформирует успех.
Effect.flatMap монадически продолжает (используется внутри Effect.gen через yield*).
Effect.catch обрабатывает любую ошибку. Если хочешь обрабатывать только конкретный тег, бери Effect.catchTag или Effect.catchTags.
const safe = pipe(
fetchWeather('msk'),
Effect.catch((e) =>
Effect.succeed({ city: 'msk', temp: 0, fallback: true }),
),
);
Effect.timeout(duration) ставит лимит на эффект. Если не успел, вылетает ошибка TimeoutError.
const limited = pipe(
fetchWeather('msk'),
Effect.timeout('5 seconds'),
);
Effect.retry(schedule) повторяет эффект по политике. Schedule это ленивое описание того, “как и сколько раз пробовать”.
import { Schedule } from 'effect';
const robust = pipe(
fetchWeather('msk'),
Effect.retry({ schedule: Schedule.exponential('100 millis'), times: 5 }),
);
Schedule.exponential(start) это нарастающая пауза: 100 мс, 200 мс, 400 мс, 800 мс. Schedule.recurs(n) ограничивает количество (то же самое короче пишется полем times). Schedule.spaced(duration) это фиксированная пауза. Две политики складываются через Schedule.max (берём большую задержку) и Schedule.min (меньшую), а Schedule.concat переключает со второй после первой.
Effect.forkChild запускает эффект в файбере (лёгкий поток). Возвращает Fiber, результат которого забирают через Fiber.join, а прерывают через Fiber.interrupt.
Effect.fail и Effect.succeed базовые конструкторы для точечных значений.
Поиграй с виджетом-рельсами. Четыре шага, можешь пометить любой как падающий, переключить режим повторов. Видна суть ROP плюс повторы, которые в Effect-TS встроены.
4. Layer и Service: функциональное внедрение зависимостей
Самая большая фича, которая отличает Effect от neverthrow, это управление зависимостями через типы.
Объявление сервиса.
import { Context, Effect } from 'effect';
class HttpService extends Context.Service<
HttpService,
{ readonly get: (url: string) => Effect.Effect<unknown, FetchError> }
>()('HttpService') {}
Использование внутри эффекта.
const fetchWeather = (city: string) =>
Effect.gen(function* () {
const http = yield* HttpService;
const data = yield* http.get(`/api/weather/${city}`);
return data as Weather; // в реале yield* Schema.decodeUnknownEffect(...)
});
// fetchWeather('msk'): Effect<Weather, FetchError, HttpService>
Тип R теперь содержит HttpService. Запустить программу без сервиса нельзя, компилятор не даст.
Реализация через Layer.
import { Layer } from 'effect';
const HttpServiceLive = Layer.succeed(HttpService, {
get: (url) =>
Effect.tryPromise({
try: () => fetch(url).then((r) => r.json()),
catch: (e) => new FetchError({ message: String(e) }),
}),
});
// для тестов: заглушка-Layer
const HttpServiceTest = Layer.succeed(HttpService, {
get: () => Effect.succeed({ city: 'msk', temp: 5 }),
});
Запуск с конкретным Layer.
const main = pipe(
fetchWeather('msk'),
Effect.provide(HttpServiceLive),
);
Effect.runPromise(main).then(console.log);
В тестах подставляешь HttpServiceTest, и вся бизнес-логика идёт без сети. Никаких jest-моков, всё на типах.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.