javascript

10 причин попробовать Effect TS/Основы Effect TS

  • пятница, 13 марта 2026 г. в 00:00:10
https://habr.com/ru/articles/1009458/

Effect-фреймворк, который не может не вызвать эмоции у разбирающего с ней разработчика: либо неподдельный восторг, либо отвращение от синтаксиса, ненужного бойлерплейта и неоправданной сложности. Но, как говорится, от любви до ненависти один шаг. В этой статье постараюсь объяснить, что вы получаете в обмен на нетипичный синтаксис и бойлерплейт, и тем самым убедить попробовать Effect в вашем следующем пет проекте.

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

Также я не рекомендую пробовать Effect только на фронтенде, несмотря на то, что есть хороший стейт менеджер effect-atom. Однако, если ваш бэкенд на effect, и вы сами уже ознакомились с этой технологией достаточно близко, effect-atom вероятно будет самым лучшим выбором стейт менеджера для вас

И последнее: сейчас уже в бету вышла новая версия эффекта: Effect v4. Она порядком быстрее, бандл намного меньше весит, API переработан. Но так как она все еще в бете и неизвестно сколько в таком состоянии пробудет, в статье я рассказываю про Effect v3.

Причины

Начну я с конца и перечислю те самые причины, почему стоит попробовать Effect:

  1. Типизированные ошибки (самая расхайпленная причина, но не самая главная)

  2. Типизированный и очень гибкий Dependency Injection (если какой-то сервис без имплементации, то компилятор начнет жаловаться)

  3. Testability - благодаря DI любой сервис тривиально мокается (типизированно), процесс написания тестов становится безболезненным

  4. Высокая композабельность (разные утилиты хорошо объединяются друг с другом )

  5. Легко интегрируется с уже существующей тайпскриптовой кодовой базой, так как это все еще тайпскрипт. Вы не обязаны писать на Effect все, вы пишете на нем ровно столько, сколько посчитаете нужным. Я вот считаю нужным писать на Effect все

  6. Мощная работа с конкурентностью: structured concurrency, конкурентные задачи, их отмена, повторные попытки при ошибках, таймауты решаются единообразно, без необходимости собирать решение из разных библиотек

  7. Очень богатый набор утилит на все случаи жизни (Queue, Stream, Schema, Schedule, Duration, DateTime, PubSub, Semaphore, Software Transactional Memory, и так далее)

  8. Observability-встроенная поддержка трейсинга (OpenTelemetry), логирования и метрик. При использовании Effect.fn спаны создаются автоматически, а подключить провайдер-дело одной строки.

  9. Имеет много полезных пакетов, которые также поддерживаются командой эффекта и считаются официальными. Мой верный друг Claude вкратце расскажет вам о некоторых из них:

    1. @effect/platform - HTTP-сервер/клиент, файловая система, WebSocket, работа с процессами и другие платформенные абстракции (@effect/platform-node или @effect/platform-bun для конкретного рантайма)

    2. @effect/cli - декларативное построение CLI-приложений с типизированными командами, аргументами, флагами и автогенерацией help

    3. @effect/ai - абстракции LanguageModel, EmbeddingModel, Chat (с историей), Tool/Toolkit (tool calling), Prompt, Tokenizer, McpServer и телеметрия. Провайдер-агностик.

    4. @effect/sql - типобезопасный SQL-клиент с tagged template literals для запросов, миграциями и resolvers; адаптеры для Postgres, MySQL, SQLite, ClickHouse и др.

    5. @effect/rpc - типобезопасные remote procedure calls: определяешь запросы через Schema, получаешь автоматическую сериализацию, валидацию и транспорт (HTTP, WebSocket и т.д.)

    6. @effect/cluster - распределённые системы: sharding, entity-модель (virtual actors), singleton-сервисы, message routing между нодами кластера, cron и workflows

  10. Активно поддерживается, команда регулярно ведет стримы, где они разбирают кейсы зрителей и отвечают на вопросы

То есть Effect-это своего рода стандартная библиотека для тайпскрипта, которая максимизирует типобезопасность и убирает необходимость склеивать разные библиотеки друг с другом. Теперь у вас есть один мощный фреймворк, который написан одной командой разработчиков, благодаря чему он единообразный.

Effect также прекрасно подходит для работы с LLM. Все пакеты лежат в одной репозитории поэтому вы просто можете склонировать ее к себе в проект, добавить ее в .gitignore, и сказать LLM, что ответы на все вопросы искать нужно там. А благодаря максимизированной типобезопасности, LLM получает хороший feedback от компилятора и знают, что сделали не так. Таким образом, вариантов накосячить у LLM становится значительно меньше.

Далее в этой статье я буду давать подтверждение первых шести причин. Дабы не перегружать читателя информацией, подтверждать остальные причины я даже не буду пытаться.

Основы Effect

Когда я первый раз открыл документацию Effect, для меня это все показалось каким-то излишеством, непонятно зачем и для кого сделанным. Однако, со временем, я переборол свою предвзятость и решил написать пет проект в виде чат-бота с несколько сложной логикой (не буду вдаваться в детали) на Effect и мое мнение кардинально изменилось. Если у вас сложилось похожее мнение после прочтения документации, либо вы вообще понятия не имеете, что это такое, то дальше я постараюсь рассказать про самые базовые понятия этого фреймворка так просто, как получится

Effect<A, E, R>

Весь Effect построен вокруг одного типа:

Effect<A, E, R>

Проще всего понять его через сравнение с Promise<A>. Промис - это описание асинхронной операции, которая либо вернёт значение типа A, либо завершится ошибкой. Но у промиса есть ограничения: тип ошибки всегда unknown, и непонятно, какие зависимости нужны для выполнения.

Effect<A, E, R> решает оба этих момента:

  • A - значение в случае успеха

  • E - типизированная ошибка

  • R - зависимости (об этом позже)

Effect не выполняется сразу при создании, в отличие от промиса, он просто описывает что должно произойти

Самый простой способ создать эффект - Effect.succeed и Effect.fail:

import { Effect } from "effect"

const success = Effect.succeed(42) // Effect<number, never, never>
const failure = Effect.fail("что-то пошло не так") // Effect<never, string, never>

never в позиции E означает, что эффект не может завершиться ошибкой. never в позиции A - что он не вернёт значение в случае успеха.

Также есть Effect.sync - он нужен когда вы хотите обернуть синхронный код, который точно не бросит исключение:

const random = Effect.sync(() => Math.random()) // Effect<number, never, never>

Для написания более сложной логики используется Effect.gen. В него мы передаем генератор. Синтаксис function* и yield* хоть и нативный, но непривычный. Однако к нему быстро привыкаешь, и вдаваться в детали того, как работают генераторы, не нужно, просто воспринимайте это как async/await:

// async/await
const program = async () => {
  const a = await fetchA()
  const b = await fetchB()
  return a + b
}

// Effect.gen
const program = Effect.gen(function* () {
  const a = yield* effectA
  const b = yield* effectB
  return a + b
})

Почему команда Effect выбрала именно генераторы я точно вам сказать не могу. Но есть подозрение, что такую же мощную в плане типобезопасности альтернативу на других примитивах построить было бы значительно сложнее, если вообще возможно

Если какой-то из эффектов завершится ошибкой, выполнение прервётся и ошибка поднимется наверх, точно так же, как это работает с async/await

Сам по себе эффект ничего не делает. Чтобы его запустить, используется функцияEffect.runPromise, которая возвращает обычный промис:

import { Effect } from "effect"

const program = Effect.gen(function* () {
  const a = yield* Effect.succeed(10)
  const b = yield* Effect.succeed(32)
  return a + b
})

Effect.runPromise(program).then(console.log) // 42

Если эффект завершится ошибкой, промис будет отклонён:

const program = Effect.fail("что-то пошло не так")

Effect.runPromise(program).catch(console.error) // что-то пошло не так

Работа с ошибками

Представьте: у нас есть эндпоинт который получает userId и должен вернуть пользователя. Для этого он обращается к внешнему сервису через HTTP запрос. Этот запрос может упасть по разным причинам - пользователь не найден, токен истек, сеть недоступна. Каждую из этих ситуаций мы хотим обработать по-своему и в итоге вернуть клиенту понятный HTTP-ответ

Для начала создадим типы ошибок на каждый кейс. Для этого в Effect используется Data.TaggedError:

import { Data, } from "effect"

// пользователь не найден
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
  userId: string
}> {}

// токен протух или отсутствует
class UnauthorizedError extends Data.TaggedError("UnauthorizedError")<{
  message?: string
}> {}

// сеть недоступна или запрос не выполнился
class NetworkError extends Data.TaggedError("NetworkError")<{
  message: string
  cause: unknown // оригинальная ошибка для отладки
}> {}

Бойлерплейтно, но на практике это не проблема, так как такие классы я всегда генерирую LLMкой.

Важный нюанс: строка которую мы передаём в Data.TaggedError(...) записывается в поле tag каждого экземпляра. То есть у любого new NetworkError(...) будет tag === "NetworkError", у new UserNotFoundError(...) _tag === "UserNotFoundError". Именно по этому полю Effect в рантайме различает типы ошибок - это удобнее чем instanceof: IDE знает все возможные теги как строковые литералы и подсказывает их прямо в автодополнении, поэтому опечататься или забыть про какую-то ошибку просто не получится.

Для того чтобы обернуть существующий промис в эффект, используется Effect.tryPromise. try - промис с основной логикой, catch -функция которая получает аргументом всё что было брошено или реджектнулось, и возвращает типизированную ошибку. Обернём с его помощью fetch юзера:

import { Effect } from "effect"

interface User {
  id: string
  email: string
  name: string
}

const getUser = (userId: string) => Effect.tryPromise({
  try: async () => {
    const res = await fetch(`https://api.example.com/users/${userId}`)
    if (res.status === 404) throw new UserNotFoundError({ userId })
    if (res.status === 401) throw new UnauthorizedError()
    if (!res.ok) throw new Error(`unexpected status: ${res.status}`)
    return res.json() as Promise<User>
  },
  catch: (e) => {
    if (e instanceof UserNotFoundError) return e
    if (e instanceof UnauthorizedError) return e
    return new NetworkError({ message: String(e), cause: e })
  }
})
// (userId: string) => Effect<User, UserNotFoundError | UnauthorizedError | NetworkError, never>

Именно так Effect и интегрируется с существующим кодом - Effect.tryPromise позволяет завернуть любой промис в эффект, а Effect.runPromise - запустить эффект обратно как промис. То есть вы можете постепенно переводить код на Effect, не переписывая всё сразу

Прежде чем ловить ошибки, разберёмся с pipe, он будет везде.

Представьте что у вас есть несколько функций:

const double = (n: number) => n * 2
const addTen = (n: number) => n + 10
const toString = (n: number) => `результат: ${n}`

Без pipe вызов выглядит так:

toString(addTen(double(5))) // "результат: 20"

Читать это приходится справа налево - неудобно. С pipe:

import { pipe } from "effect"

pipe(
  5,
  double,
  addTen,
  toString
) // "результат: 20"

Слева направо (сверху вниз), каждая функция получает результат предыдущей. Теперь про базовые инструменты Effect которые часто встречаются в pipe:

  • Effect.map - трансформирует значение внутри эффекта, не трогая ошибки

  • Effect.flatMap - то же самое, но когда трансформация сама возвращает эффект

  • Effect.tap - выполняет побочное действие (например логирование), не меняя значение

  • Effect.tapError - то же самое, но срабатывает при ошибке. Удобно, когда надо залогировать её не прерывая цепочку обработки

  • Effect.retry - перевыполнение в случае ошибки

  • Effect.repeat - повторение эффекта

  • Effect.catchTag - ловит конкретную ошибку по тэгу

  • Effect.catchTags - ловит ошибки по нескольким тэгам

  • Effect.catchAll — ловит все ошибки

в Effect у большинства типов есть .pipe на уровне прототипа (Effect, Stream, Schema...), поэтому можно писать что-то в таком духе (для примера, предположим что у нас есть fetchTodos: Effect<Todo[], SomeError, never> и saveTodos: (todos: Todo[]) => Effect<void, SomeOtherError, never>)

const program = fetchTodos.pipe(
  Effect.map(todos => todos.filter(t => !t.completed)), // оставим только незавершённые
  Effect.tap(todos => Effect.log(`получили ${todos.length} тудушек`)), // залогируем
  Effect.tapError(e => Effect.log(`ошибка: ${e._tag}: ${e.message}`)), // залогируем ошибку если она есть
  Effect.flatMap(todos => saveTodos(todos)) // сохраним — saveTodos тоже возвращает эффект
)

Важный момент -pipe универсален, но утилиты нужно брать из правильного модуля. В Effect есть и другие типы со своими утилитами: Stream, Option, Schema и другие. Поэтому каждый раз, когда вы видите Effect.map, Effect.tap и т.д - это не просто повторение слова "Effect", а явная семантика: мы работаем именно с эффектом, а не со стримом или опшном

Теперь применим всё это к нашему getUser:

import {Effect, Schedule, Data} from 'effect';

//Добавим ещё один класс — HttpError. В реальном проекте это была бы ошибка из HTTP-фреймворка, здесь мы создаём её сами для примера:

class HttpError extends Data.TaggedError("HttpError")<{
  status: number
  message: string
}> {}



const getUserEndpoint = (userId: string) => getUser(userId).pipe(
  // логируем результат
  Effect.tap(res => Effect.log(`Результат: ${res}`)),
  // логируем любую ошибку не прерывая цепочку
  Effect.tapError(e => Effect.logError(`ошибка: ${e._tag}`)),
  // ретраим только сетевые ошибки с экспоненциальным бэкоффом
  Effect.retry({
    schedule: Schedule.exponential("1 second"),
    while: (e) => e._tag === "NetworkError"
  }),
  // маппим доменные ошибки в HTTP-ответы
  Effect.catchTag("UserNotFoundError", (e) =>
    Effect.fail(new HttpError({ status: 404, message: `пользователь ${e.userId} не найден` }))
  ),
  Effect.catchTag("UnauthorizedError", () =>
    Effect.fail(new HttpError({ status: 401, message: "не авторизован" })) 
  ),
  // после исчерпания ретраев NetworkError превращается в 500
  Effect.catchTag("NetworkError", () =>
    Effect.fail(new HttpError({ status: 500, message: "внутренняя ошибка сервера" }))
  )
)
// Effect<User, HttpError, never>

После того как мы обработали все три ошибки через catchTag, компилятор убирает их из типа — в итоге E содержит только HttpError. Если бы мы забыли обработать какую-то ошибку, она бы осталась в типе и компилятор не дал бы нам это проигнорировать.

Dependency Injection

Services

DI в Effect крутится вокруг понятия сервисов и Layer (слоев). Сервисы-это просто набор каких-то функций, который можно инжектить в эффекты и слои (об этом попозже). Но давайте по порядку, для начала создадим сервис на основе уже написанной нами функции getUser

Для объявления сервиса используется Context.Tag:

import { Context, Effect } from "effect"

class UserService extends Context.Tag("UserService")<
  UserService,
  { getUser: (userId: string) => Effect.Effect<User, UserNotFoundError | UnauthorizedError | NetworkError> }
>() {}

Context.Tag создаёт уникальный идентификатор сервиса - строка "UserService" это ключ по которому Effect будет искать имплементацию в рантайме (что-то типо tag для ошибок). Второй параметр - интерфейс сервиса.

Теперь напишем функцию которая его использует:

const program = Effect.gen(function* () {
  const userService = yield* UserService
  const userId = 'someRandomId';
  const user = yield* userService.getUser(userId)
  return user
})
// Effect<User, UserNotFoundError | UnauthorizedError | NetworkError, UserService>

В типе появился UserService в позиции R. Это значит, что эффект требует имплементацию UserService для запуска

Тут хотелось бы подсветить два момента:

  • Компилятор понимает, что program зависит от UserService и нам не нужно вручную это нигде типизировать

  • Компилятор понимает, что userService.getUser может упасть с ошибками UserNotFoundError | UnauthorizedError | NetworkError , и соответственно program тоже может упасть с теми же ошибками (ошибки всплывают наверх), и нам не нужно это вручную типизировать

Если попробовать запустить program через Effect.runPromise без имплементации, компилятор начнёт жаловаться.

Effect.runPromise(program) //...Type 'UserService' is not assignable to type 'never'

Для предоставления имплементации используется Effect.provideService. Имплементацией сервиса может быть обычный объект, который соответствует контракту сервиса. Обычно такие объекты называют с суффиксом Live. В нашем случае это будет UserServiceLive

const UserServiceLive = UserService.make({   //UserService.make возвращает обычный объект, можно обойтись и без этой функции, но тогда нам IDE не будет подсказывать типы сервиса при написания имплементации
  getUser: (userId) => Effect.tryPromise({
    try: async () => {
      const res = await fetch(`https://api.example.com/users/${userId}`)
      if (res.status === 404) throw new UserNotFoundError({ userId })
      if (res.status === 401) throw new UnauthorizedError()
      if (!res.ok) throw new Error(`unexpected status: ${res.status}`)
      return res.json() as Promise<User>
    },
    catch: (e) => {
      if (e instanceof UserNotFoundError) return e
      if (e instanceof UnauthorizedError) return e
      return new NetworkError({ message: String(e), cause: e })
    }
  })
})

const program = Effect.gen(function* () {
  const userService = yield* UserService
  const userId = 'someRandomId';
  const user = yield* userService.getUser(userId)
  return user
}) //дублирую для вашего удобства

const programWithoutDependencies = program.pipe(
  Effect.provideService(UserService, UserServiceLive)
)
//Effect <User, UserNotFoundError | UnauthorizedError | NetworkError, never>

Effect.runPromise(programWithoutDependencies)

После provideService UserService уходит из типа, эффект больше ни от чего не зависит и готов к запуску.

Такой способ внедрения зависимостей очень удобен при написании тестов. Если вы разбиваете вашу бизнес логику на сервисы, то в тестах можете любой сервис подменить на что угодно. Например:

import {vi} from 'vitest'
import {Effect} from 'effect';

const UserServiceMockSucceed = UserService.make({   //мок для симуляции успешного кейса
  getUser: vi.fn(() => Effect.succeed(mockUser)), //в дальнейшем можем использовать expect.toHaveBeenCalledWith
})

const UserServiceMockNotFound = UserService.make({   //мок для симуляции ошибки UserNotFoundError
  getUser: vi.fn(() => Effect.fail(new UserNotFoundError({userId: 'testId'}))),
})


const testProgramSucceed = program.pipe(
  Effect.provideService(UserService, UserServiceMockSucceed)
)

const testProgramNotFound = program.pipe(
  Effect.provideService(UserService, UserServiceMockNotFound)
)

Я не стал писать полноценные тесты, но, к сведению, делается это с помощью @effect/vitest. В целом, надеюсь, идею вы уловили.

Сейчас у нас getUser в UserService ни от чего не зависит.

class UserService extends Context.Tag("UserService")<
  UserService,
  { getUser: (userId: string) => Effect.Effect<User, UserNotFoundError | UnauthorizedError | NetworkError> }
>() {}

Но, по идее, там происходит HTTP запрос и он должен зависеть от HTTP клиента. Поэтому давайте напишем свой сервис HttpClient. Он будет максимально простой и тупой, но полноценный сервис уже есть в пакете @effect/platform

import {Effect, Data, Context} from 'effect';

// запрос прошел не с 200 статусом
class RequestError extends Data.TaggedError("RequestError")<{
  status: number
  url: string
}> {}

// сеть недоступна или запрос не выполнился. 
//Эта ошибка уже была объявлена выше, я просто дублирую это
class NetworkError extends Data.TaggedError("NetworkError")<{
  message: string
  cause: unknown // оригинальная ошибка для отладки
}> {}

class HttpClient extends Context.Tag("HttpClient")<
  HttpClient,
  { fetch: (url: string) => Effect.Effect<unknown, RequestError | NetworkError> }
>() {}


//создаем имплементацию с помощью fetch
const HttpClientLive = HttpClient.make({
  fetch: (url) => Effect.tryPromise({
    try: async () => {
      const res = await fetch(url)
      if (!res.ok) throw new RequestError({ status: res.status, url })
      return res.json()
    },
    catch: (e) => {
      if (e instanceof RequestError) return e
      return new NetworkError({ message: String(e), cause: e })
    }
  })
})

Теперь мы можем переписать имлементацию UserService, чтобы он учитывал HttpClient:

import { Context, Effect } from "effect"

class UserService extends Context.Tag("UserService")<
  UserService,
  { getUser: (userId: string) => Effect.Effect<User, UserNotFoundError | UnauthorizedError | NetworkError, HttpClient> }
>() {}

//обратите внимание, что появился HttpClient в зависимостях getUser

const UserServiceLive = UserService.make({
  getUser: (userId) => Effect.gen(function* () {
    const httpClient = yield* HttpClient //теперь getUser зависит от HttpClient
    return yield* httpClient.fetch(`https://api.example.com/users/${userId}`).pipe(
      Effect.map(data => data as User), //забудем про валидацию
      Effect.catchTag("RequestError", (e) => {
        if (e.status === 404) return Effect.fail(new UserNotFoundError({ userId }))
        if (e.status === 401) return Effect.fail(new UnauthorizedError())
        return Effect.fail(new NetworkError({ message: `Unexpected status: ${e.status}`, cause: e }))
      })
    )
  })
})

И все вроде хорошо, но есть одна проблема. А что если завтра мы захотим имплементацию UserService, которая будет тянуть пользователя не через HTTP запрос, а через базу данных либо GraphQL клиент? Не будем же мы под каждый кейс городить сервис. Деталь имплементации утекает в контракт сервиса, и это плохо. Значит, нам как-то нужно избавиться от зависимости от HttpClient. При этом имплементация, которую мы имеем (UserServiceLive) сейчас должна все еще зависеть от HttpClient. Эта проблема называется service leak и для ее решения ввели понятие Layer (слой)

Layer

Layer - это имплементация сервиса со своей логикой инициализации. Именно в этой логике вы получаете нужные зависимости, делаете всё что нужно при старте, и возвращаете готовый сервис. Зависимости остаются внутри слоя и не утекают в интерфейс.

Для создания слоя используется Layer.effect . Первым аргументом он принимает сервис, а вторым Effect, который его реализует. Как раз в этом эффекте мы можем получить нужные зависимости через yield*:

import {Layer, Effect} from 'effect';

class UserService extends Context.Tag("UserService")<
  UserService,
  { getUser: (userId: string) => Effect.Effect<User, UserNotFoundError | UnauthorizedError | NetworkError, never> }
>() {}

//зависимости getUser снова never, теперь детали имплементации не утекают наружу

//создаем слой
const UserServiceLive = Layer.effect(
  UserService,
  Effect.gen(function* () {
    const httpClient = yield* HttpClient //сервис получаем внутри слоя, не внутри getUser
    return {
      getUser: (userId) => httpClient.fetch(`https://api.example.com/users/${userId}`).pipe(
        Effect.map(data => data as User), // опускаем валидацию
        Effect.catchTag("RequestError", (e) => {
          if (e.status === 404) return Effect.fail(new UserNotFoundError({ userId }))
          if (e.status === 401) return Effect.fail(new UnauthorizedError())
          return Effect.fail(new NetworkError({ message: `unexpected status: ${e.status}`, cause: e}))
        })
      )
    }
  })
)
// Layer<UserService, never, HttpClient>

Тип Layer<UserService, never, HttpClient> говорит: этот слой предоставляет UserService, не имеет ошибок, и требует HttpClient

В Layer, помимо получения сервисов, можно делать тоже самое что в обычных эффектах (что угодно). Слой, как видно из типа, может упасть с ошибкой, которую можно обработать. Все это запускается в момент, когда вы предоставляете слой в качестве имплементации программе. Поэтому лучше предоставлять слои один раз в одном месте, чтобы логика конструкции слоя не дублировалась

Давайте теперь склеим все что мы написали в одну большую портянку:

import {Effect, Context, Data, Layer} from 'effect';


// --------- ERRORS ------------

// запрос прошел не с 200 статусом
class RequestError extends Data.TaggedError("RequestError")<{
  status: number
  url: string
}> {}

// сеть недоступна или запрос не выполнился. 
//Эта ошибка уже была объявлена выше, я просто дублирую это
class NetworkError extends Data.TaggedError("NetworkError")<{
  message: string
  cause: unknown // оригинальная ошибка для отладки
}> {}

// пользователь не найден
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
  userId: string
}> {}

// токен протух или отсутствует
class UnauthorizedError extends Data.TaggedError("UnauthorizedError")<{
  message?: string
}> {}



// --------- HttpClient ------------

class HttpClient extends Context.Tag("HttpClient")<
  HttpClient,
  { fetch: (url: string) => Effect.Effect<unknown, RequestError | NetworkError> }
>() {}


//несмотря на то, что HttpClient не имеет зависимостей, для конзистентности, 
//я превратил его в слой с помощью Layer.succeed
const HttpClientLive = Layer.succeed(HttpClient, {
  fetch: (url) => Effect.tryPromise({
    try: async () => {
      const res = await fetch(url)
      if (!res.ok) throw new RequestError({ status: res.status, url })
      return res.json()
    },
    catch: (e) => {
      if (e instanceof RequestError) return e
      return new NetworkError({ message: String(e), cause: e })
    }
  })
}) 
//Layer<HttpClient, never, never>
//слой ни от чего не зависит, никаких ошибок не имеет, и просто реализует HttpClient


// ------------- UserService -----------


class UserService extends Context.Tag("UserService")<
  UserService,
  { getUser: (userId: string) => Effect.Effect<User, UserNotFoundError | UnauthorizedError | NetworkError, never> }
>() {}

const UserServiceLive = Layer.effect(
  UserService,
  Effect.gen(function* () {
    const httpClient = yield* HttpClient //сервис получаем внутри слоя, не внутри getUser
    return {
      getUser: (userId) => httpClient.fetch(`https://api.example.com/users/${userId}`).pipe(
        Effect.map(data => data as User), // опускаем валидацию
        Effect.catchTag("RequestError", (e) => {
          if (e.status === 404) return Effect.fail(new UserNotFoundError({ userId }))
          if (e.status === 401) return Effect.fail(new UnauthorizedError())
          return Effect.fail(new NetworkError({ message: `unexpected status: ${e.status}`, cause: e}))
        })
      )
    }
  })
)
// Layer<UserService, never, HttpClient>


const program = Effect.gen(function* () {
  const userService = yield* UserService
  const userId = 'someRandomId';
  const user = yield* userService.getUser(userId)
  return user
})


Effect.runPromise(program.pipe(
  Effect.provide(UserServiceLive),
  Effect.provide(HttpClientLive)
))

Заметьте, когда мы провайдим Layer, мы используем Effect.provide, а не Effect.provideService.

Подытожу:

  • Dependency Injection в Effect contract-first. То есть мы сначала описываем интерфейс. А имплементацию мы внедряем в самом конце-когда запускаем программу

  • Effect трекает зависимости каждого эффекта и компилятор сам понимает, когда все зависимости были удовлетворены. Вам не нужно запускать программу, чтобы понять, что вы забыли прокинуть имплементацию

  • В случае, когда имплементация нашего сервиса (почти всегда) зависит от других сервисов, мы используем Layer, он позволяет получить зависимости на стадии конструкции слоя, а не в самих методах. Соответственно наша имплементация становится зависима от других сервисов, при этом сами интерфейсы остаются независимыми. Это дает гибкость в проектировании сервисов

  • Предоставляем слои один раз, чтобы не дублировать логику конструкции слоя

  • Предоставляем имплементацию с помощью Effect.provideService (в основном в тестах) и Effect.provide (для предоставления Layer)

Возможно у вас возникло опасение, что при большом количестве сервисов, граф зависимостей будет очень сложным и руками это все собирать будет трудно. Effect позаботился о нас и предоставил утилиты для удобного управления слоями: Layer.merge, Layer.provideMerge и так далее. Но в рамках этой статьи не буду вдаваться в такие детали

Конкурентность/Fiber

Если вы усвоили все, что я выше расписал с первого раза, вы большой молодец и должны были получить подтверждения нескольким изначально описанным мною причинам (см. причины 1, 2, 3, 4, 5). Сейчас же затронем тему конкурентности в Effect.

В основе конкурентности в Effect лежат файберы (Fiber). Файбер — это легковесный поток выполнения, которым управляет сам Effect, а не операционная система. Это значит что можно запускать тысячи файберов одновременно без overhead нативных тредов.

Каждый раз когда вы запускаете эффект через Effect.runPromise — под капотом создаётся файбер. В большинстве случаев вы не работаете с файберами напрямую, но понимать что они есть — важно.

Самый простой способ запустить несколько эффектов параллельно — Effect.all с опцией concurrency:

//....
const getUsers = Effect.all(
  ["1", "2", "3"].map(id => userService.getUser(id)),
  { concurrency: "unbounded" } // все запросы параллельно
)
// Effect<User[], UserNotFoundError | UnauthorizedError | NetworkError, UserService>

Или с ограничением параллельности:

//....
const getUsers = Effect.all(
  ["1", "2", "3"].map(id => userService.getUser(id)),
  { concurrency: 2 } // не более 2 запросов одновременно
)

Если один из эффектов упадёт с ошибкой - остальные автоматически прервутся и результат вернётся сразу, не дожидаясь остальных.

Effect также позволяет запускать файберы вручную через Effect.fork. Effect реализует structured concurrency - время жизни дочернего файбера не может превышать время жизни родительского. Это значит, что когда родительский файбер завершается, все дочерние автоматически прерываются:

const program = Effect.gen(function* () {
  const userService = yield* UserService

  // запускаем дочерний файбер в фоне
  yield* Effect.fork(
    Effect.gen(function* () {
      yield* Effect.sleep("10 seconds")
      yield* userService.getUser("1")
      yield* Effect.log("это не выполнится")
    })
  )

  // родительский файбер завершается
  yield* Effect.log("родитель завершился")
})

// когда родительский файбер завершится — дочерний автоматически прервётся
// никаких висящих в фоне задач

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

Давайте покажем более реальный пример. Допустим, нам нужно периодически проверять доступность сервиса в фоне пока работает основная программа:

import {Schedule, Effect} from 'effect';

const healthCheck = Effect.gen(function* () {
  const httpClient = yield* HttpClient
  yield* httpClient.fetch("https://api.example.com/health").pipe(
    Effect.tap(() => Effect.log("сервис доступен")),
    Effect.tapError(() => Effect.log("сервис недоступен")),
  )
})

const program = Effect.gen(function* () {
  const userService = yield* UserService

  // запускаем health check в фоне, не блокируем основной файбер
  const healthFiber = yield* healthCheck.pipe(
    Effect.repeat(Schedule.spaced("10 seconds")),
    Effect.fork
  )

  // основная программа продолжает работать
  const user = yield* userService.getUser("1")
  yield* Effect.log(`получили пользователя ${user.id}`)
  //... много много сложной логики
})

Тут healthFiber выключится как только завершится логика program. Если мы хотим этого избежать, можно использовать Effect.forkDaemon

Effect.forkDaemon - это как Effect.fork, но файбер становится дочерним не текущего файбера, а корневого. То есть, он живёт независимо от того, кто его запустил, даже если родительский файбер завершится, daemon-файбер продолжит работать. Это полезно для задач которые должны жить на протяжении всего времени работы программы, например, тот же health check который мы написали выше

const program = Effect.gen(function* () {
  const userService = yield* UserService

  // запускаем health check в фоне, не блокируем основной файбер
  const healthFiber = yield* healthCheck.pipe(
    Effect.repeat(Schedule.spaced("10 seconds")),
    Effect.forkDaemon //теперь это будет крутиться даже после выполнения program
  )

  // основная программа продолжает работать
  const user = yield* userService.getUser("1")
  yield* Effect.log(`получили пользователя ${user.id}`)
  //... много много сложной логики
})

А для того, чтобы вручную отменить файбер, используем Fiber.interrupt(healthFiber)

Либо же мы можем дождаться выполнения файбера (в нашем случае дождаться не получится, т.к выполняться он будет бесконечно) с помощью Fiber.join

Как по мне, такой подход удобнее и приятнее, чем бесконечные abortController и signal.

Scope

И последнее, о чем я вам сегодня расскажу в своей статье-это механизм Scope. Scope - это время жизни эффекта. Это удобно, потому что можно подвязаться на это время жизни и выполнить финализатор в его конце, либо привязать файбер к скоупу, а не к родительскому файберу.

Базовый пример с Effect.addFinalizer:

const program = Effect.gen(function* () {
  yield* Effect.addFinalizer(() => Effect.log("скоуп закрылся, чистим ресурсы"))
  
  const userService = yield* UserService
  const user = yield* userService.getUser("1")
  return user
})
// Effect<User, HttpError, UserService | Scope>

// провайдим Scope с помощью Effect.scoped
const scoped = Effect.scoped(program)
// Effect<User, HttpError, UserService>

Как видите, после добавления Effect.addFinalizer в зависимостях появился Scope автоматически. Далее мы удовлетворяем эту зависимость с помощью Effect.scoped. Effect.scoped создаёт скоуп, запускает эффект внутри него и закрывает скоуп по завершении, финализатор выполнится в любом случае, даже если эффект упал с ошибкой.

Для того чтобы привязать файбер к скоупу есть два метода: Effect.forkScoped привязывает файбер к текущему скоупу, Effect.forkIn позволяет явно передать скоуп к которому нужно привязать файбер. В отличие от Effect.fork такой файбер живёт не столько сколько родительский файбер, а столько сколько живёт скоуп:

const program = Effect.gen(function* () {
  const scope = yield* Scope.make()
  
  // запускаем healthCheck привязанный к скоупу
  yield* Effect.forkIn(healthCheck, scope)
  
  const userService = yield* UserService
  
  // этот файбер завершается, но healthCheck продолжает работать
  const user = yield* userService.getUser("1")
  yield* Effect.log(`получили пользователя ${user.id}`)
  
  // ждём 5 минут и закрываем скоуп — healthCheck прервётся
  yield* Effect.sleep("5 minutes")
  yield* Scope.close(scope, Exit.succeed(void 0))
  
  return user
})

Разница между вариантами запуска файбера:

  • Effect.fork — файбер живёт пока живёт родительский файбер

  • Effect.forkScoped — файбер живёт пока живёт текущий скоуп

  • Effect.forkIn — файбер живёт пока живёт переданный скоуп

  • Effect.forkDaemon — файбер живёт пока живёт вся программа

В реальных кейсах скоуп будет встречаться довольно часто. Самый частый кейс-менеджмент ресурсов: закрытие соединений к базе данных, вебсокет серверу и так далее. Ниже продемонстрирую приближенный к реальному код для работы с данными свечей бинанса с помощью вебсокетов, скоупов и потоков:

import {Effect, Context, Stream, Layer} from 'effect';

interface BinanceCandle {
  close: number
  // ...
}

class BinanceService extends Context.Tag("BinanceService")
  BinanceService,
  { 
    getBtcUsdtPrices: () => Stream.Stream<BinanceCandle, NetworkError>;
    getEthUsdtPrices: () => Stream.Stream<BinanceCandle, NetworkError>; 
  }
>() {}

const BinanceServiceLive = Layer.effect(
  BinanceService,
  Effect.gen(function* () {
    const btcWs = new WebSocket("wss://stream.binance.com/ws/btcusdt@trade")
    const ethWs = new WebSocket("wss://stream.binance.com/ws/ethusdt@trade")

    ////закрываем коннекшны при закрытии скоупа
    yield* Effect.addFinalizer(() => Effect.sync(() => btcWs.close())) 
    yield* Effect.addFinalizer(() => Effect.sync(() => ethWs.close()))

    return {
      getBtcUsdtPrices: () => Stream.async<number, NetworkError>((emit) => {
        btcWs.onmessage = (e) => emit.single(JSON.parse(e.data).p)
        btcWs.onerror = () => emit.fail(new NetworkError({ message: "ws error", cause: null }))
        btcWs.onclose = () => emit.end()
      }),
      getEthUsdtPrices: () => Stream.async<number, NetworkError>((emit) => {
        ethWs.onmessage = (e) => emit.single(JSON.parse(e.data).p)
        ethWs.onerror = () => emit.fail(new NetworkError({ message: "ws error", cause: null }))
        ethWs.onclose = () => emit.end()
      })
    }
  })
)
//Layer<BinanceService, never, Scope>
//Layer зависит от Scope. Мы обязаны предоставить скоуп, чтобы все скомпилировалось

const processCandle = (pair: string, price: number) =>
  Effect.log(`[${pair}] price: ${price}`)


const program = Effect.gen(function* () {
  const binance = yield* BinanceService

  // запускаем оба стрима в фоне, привязываем к скоупу
  yield* Effect.forkScoped(
    binance.getBtcUsdtPrices().pipe(
      Stream.tap(price => processCandle("BTCUSDT", price)),
      Stream.runDrain
    )
  )

  yield* Effect.forkScoped(
    binance.getEthUsdtPrices().pipe(
      Stream.tap(price => processCandle("ETHUSDT", price)),
      Stream.runDrain
    )
  )

  // ждём 30 секунд и выходим — скоуп закроется, оба файбера и оба WebSocket остановятся
  yield* Effect.sleep("30 seconds")
})

//Effect<void, never, BinanceService | Scope>

Я не стал ничего рассказывать про потоки в рамках этой статьи, чтобы уж слишком ее не затягивать. На этом примере вы можете видеть, как с ними работать. На самом деле, очень удобно, и я превратился из человека, который всю жизнь избегал потоки, в человека, который, в первую очередь, пытается решить проблему меняющегося состояния потоками. Но об этом как-нибудь в другой раз

Заключение

Я надеюсь, я вас убедил попробовать Effect в своем пет проекте, и очень надеюсь, что ваш опыт работы с этой технологией будет приятным. Effect имеет высокий порог вхождения, но со временем все эти новые конструкции и механизмы становятся интуитивно понятными. Конечно, вся функциональность эффекта не уникальна (кроме, наверное, типизированного DI). На каждый механизм, который я разбирал, можно будет найти npm пакет, который делает тоже самое. Но зачем? В Effect уже есть все, что нужно. Оно написано единообразно, все прекрасно друг с другом сочетается, активно поддерживается и позволяет писать хороший код, не будучи system design гением. С Effect хорошо работают LLM, и благодаря такой мощной типобезопасности LLM быстро понимает, где накосячила. А если пойти дальше и запретить ей на уровне eslint добавлять any type cast, то LLM будет выдавать достаточно качественный код.

Конечно у Effect есть свои минусы: надоедливые бойлерплейты, местами через чур богатый API, технология не популярная (особенно в СНГ), высокий порог входа, и наконец, генераторы (хотя мне они даже нравятся). Но количество и качество плюсов, как по мне, очень сильно перевешивают минусы. Да и учитывая, что все движется к тому, что весь код будет писать ИИ, бойлерплейтность и наличие звездочек в коде вряд ли на что-то будет влиять. А вот типобезопасность и возможность легко тестировать сервисы обязательно будет