Раздел 4 · TypeScript

Effect-TS на практике

senior~70 мин

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

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:

ТипAER
Promise<T>Tunknown (catch)неявно
Result<T, E>TEнеявно
ResultAsync<T, E>TEнеявно
Effect<A, E, R>AEявно

Главное преимущество, окружение явное. Можно собрать программу без рантайм-сервисов, потом проверить тип, потом подставить конкретные реализации (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.