Введение
Effect-фреймворк, который не может не вызвать эмоции у разбирающего с ней разработчика: либо неподдельный восторг, либо отвращение от синтаксиса, ненужного бойлерплейта и неоправданной сложности. Но, как говорится, от любви до ненависти один шаг. В этой статье постараюсь объяснить, что вы получаете в обмен на нетипичный синтаксис и бойлерплейт, и тем самым убедить попробовать Effect в вашем следующем пет проекте.
Effect намеренно не позиционирует себя как ФП фреймворк, хотя, по сути, таковым является. И для начала хочу подчеркнуть, что никакой базы в теории функционального программирования я не имею. В этой статье не будет никаких специфичных ФП терминов, потому что я в них не разбираюсь, и самое прекрасное, что это мне не мешает разрабатывать на Effect
Также я не рекомендую пробовать Effect только на фронтенде, несмотря на то, что есть хороший стейт менеджер effect-atom. Однако, если ваш бэкенд на effect, и вы сами уже ознакомились с этой технологией достаточно близко, effect-atom вероятно будет самым лучшим выбором стейт менеджера для вас
И последнее: сейчас уже в бету вышла новая версия эффекта: Effect v4. Она порядком быстрее, бандл намного меньше весит, API переработан. Но так как она все еще в бете и неизвестно сколько в таком состоянии пробудет, в статье я рассказываю про Effect v3.
Причины
Начну я с конца и перечислю те самые причины, почему стоит попробовать Effect:
-
Типизированные ошибки (самая расхайпленная причина, но не самая главная)
-
Типизированный и очень гибкий Dependency Injection (если какой-то сервис без имплементации, то компилятор начнет жаловаться)
-
Testability - благодаря DI любой сервис тривиально мокается (типизированно), процесс написания тестов становится безболезненным
-
Высокая композабельность (разные утилиты хорошо объединяются друг с другом )
-
Легко интегрируется с уже существующей тайпскриптовой кодовой базой, так как это все еще тайпскрипт. Вы не обязаны писать на Effect все, вы пишете на нем ровно столько, сколько посчитаете нужным. Я вот считаю нужным писать на Effect все
-
Мощная работа с конкурентностью: structured concurrency, конкурентные задачи, их отмена, повторные попытки при ошибках, таймауты решаются единообразно, без необходимости собирать решение из разных библиотек
-
Очень богатый набор утилит на все случаи жизни (Queue, Stream, Schema, Schedule, Duration, DateTime, PubSub, Semaphore, Software Transactional Memory, и так далее)
-
Observability-встроенная поддержка трейсинга (OpenTelemetry), логирования и метрик. При использовании
Effect.fnспаны создаются автоматически, а подключить провайдер-дело одной строки. -
Имеет много полезных пакетов, которые также поддерживаются командой эффекта и считаются официальными. Мой верный друг Claude вкратце расскажет вам о некоторых из них:
-
@effect/platform - HTTP-сервер/клиент, файловая система, WebSocket, работа с процессами и другие платформенные абстракции (@effect/platform-node или @effect/platform-bun для конкретного рантайма)
-
@effect/cli - декларативное построение CLI-приложений с типизированными командами, аргументами, флагами и автогенерацией help
-
@effect/ai - абстракции LanguageModel, EmbeddingModel, Chat (с историей), Tool/Toolkit (tool calling), Prompt, Tokenizer, McpServer и телеметрия. Провайдер-агностик.
-
@effect/sql - типобезопасный SQL-клиент с tagged template literals для запросов, миграциями и resolvers; адаптеры для Postgres, MySQL, SQLite, ClickHouse и др.
-
@effect/rpc - типобезопасные remote procedure calls: определяешь запросы через Schema, получаешь автоматическую сериализацию, валидацию и транспорт (HTTP, WebSocket и т.д.)
-
@effect/cluster - распределённые системы: sharding, entity-модель (virtual actors), singleton-сервисы, message routing между нодами кластера, cron и workflows
-
-
Активно поддерживается, команда регулярно ведет стримы, где они разбирают кейсы зрителей и отвечают на вопросы
То есть 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, технология не популярная (особенно в СНГ), высокий порог входа, и наконец, генераторы (хотя мне они даже нравятся). Но количество и качество плюсов, как по мне, очень сильно перевешивают минусы. Да и учитывая, что все движется к тому, что весь код будет писать ИИ, бойлерплейтность и наличие звездочек в коде вряд ли на что-то будет влиять. А вот типобезопасность и возможность легко тестировать сервисы обязательно будет
Автор: rakhimzt
