Содержание
Вы открываете функцию getUser и видите тип Promise<User>. По типу кажется, что всё просто: пришёл идентификатор — вернулся пользователь. На деле внутри уже спрятаны сеть, разбор JSON, доступ к базе и чужой таймаут из соседнего файла. TypeScript это не показывает: в сигнатуре есть только «удачный исход».
Удобная аналогия — сбор в поход. На ярлыке рюкзака написано лишь «добраться до вершины». А ещё нужно знать, что может пойти не так (обрыв тропы, плохая погода) и что обязательно взять с собой (карта, верёвка, аптечка). Обычный TypeScript часто оставляет на ярлыке только вершину. Effect заставляет дописать остальное прямо в тип: успешный результат (A), ожидаемые ошибки (E) и нужные зависимости (R) — и компилятор видит весь список до запуска программы.
Статья опирается на экосистему Effect v4 (пакет effect и связанные модули с единой нумерацией версий). Примеры сверяйте с документацией Effect именно вашей мажорной версии: между v3 и v4 менялись сервисы (Context.Service вместо ряда старых API на тегах) и организация пакетов.
Ключевые выводы
TypeScript описывает форму данных, но не полный контракт выполнения. Сигнатура Promise<User> молчит о сетевых сбоях, таймаутах и нужных репозиториях. Effect.Effect<A, E, R> явно называет успех, ожидаемые ошибки и окружение.
Главный сдвиг — программа как значение. Вы сначала составляете описание вычисления, компонуете его с другими описаниями, подставляете тестовые зависимости — и только потом запускаете. Это похоже на маршрутный лист экспедиции до выхода из лагеря.
Ошибки делятся на ожидаемые и дефекты. Ожидаемые (UserNotFound, сбой сети) живут в канале E и обрабатываются через catchTag. Дефекты — поломки инвариантов; их не маскируют «успешным» запасным ответом без политики.
Сервисы и Layer заменяют скрытые синглтоны. Контракт сервиса отделён от реализации; в тесте подставляется другой Layer, а не глобальный мок с хрупкой инициализацией.
Schema закрывает дыру типов на границе системы. Компилятор не защищает от чужого JSON. Схема декодирует unknown во внутреннюю модель и даёт ту же экосистему ошибок, что и остальной код.
Effect окупается там, где сложность уже «разъехалась». Маленький CRUD с одним HTTP-вызовом, парой полей в форме и тестом «статус 200» часто проще на обычном TypeScript: цена обучения библиотеке выше, чем польза от явных каналов ошибок и слоёв. Если команда из двух человек спокойно читает try/catch и не путается в повторах — не тащите Effect ради моды.
Другое дело — сервис, который уже разросся: платёжный шлюз, учёт клиентов, склад, почта, кэш, очередь и ещё три внутренних API. У каждого свой таймаут, свой формат сбоя и свой способ «подменить в тесте». Политики повторов живут в трёх хелперах, отмена запроса работает только в половине мест, а новый разработчик неделю узнаёт, какие исключения вообще возможны. Здесь Effect платит за себя тем, что собирает ошибки, зависимости и конкурентность в один язык типов — вместо зоопарка локальных решений.
Практический критерий: если вы уже тратите заметную долю времени на согласование инфраструктуры (повторы запросов, подмены в тестах, границы JSON), а не на доменную логику — порог окупаемости, скорее всего, пройден. Если боли нет, библиотека станет самоцелью; об этом подробнее в разделах про внедрение и выбор в 2026 году.
Когда обычного TypeScript становится мало
TypeScript отлично ловит опечатки в полях и неверные аргументы функций. Он не ловит то, что происходит во время выполнения: исключение в глубине стека, отказ третьего API, гонку при отмене запроса, забытый finally у соединения с базой.
Типичная серверная функция выглядит знакомо: принимает id, ходит в HTTP-клиент, парсит JSON, пишет в лог, при ошибке бросает или возвращает null. Коллега через месяц не знает, какие исключения возможны, нужен ли кэш и кто отвечает за таймаут. Документация в комментарии устаревает быстрее кода.
С ростом системы появляются несколько параллельных «мини-архитектур»: где-то try/catch, где-то самодельный объект результата, где-то библиотека повторов, где-то ручной AbortController. Каждый механизм решает локальную боль, но не складывается в единый язык команды.
Вопрос не в том, «плох ли TypeScript». Вопрос в том, можно ли сделать поведение программы более явным и проверяемым на уровне типов, не отказываясь от привычного Node и React. Именно здесь появляется Effect.
Что такое Effect и какую задачу он решает
Effect — библиотека и экосистема для TypeScript. Ядро даёт тип вычисления, обработку ошибок, внедрение зависимостей, управление ресурсами и конкурентность. Вокруг — Schema, Stream, Schedule, HTTP, SQL, CLI, наблюдаемость и модули для ИИ-приложений. Это не замена языка, React или Node: это способ организовать программную логику и инфраструктурные механизмы одним набором абстракций.
Связь с функциональным программированием есть, но продавать Effect как «обязательный культ монад» вредно. На практике команда пишет генераторы (Effect.gen) в стиле, близком к async/await, а композиция и явные типы ошибок дают инженерный выигрыш без обязательного жаргона в каждом ревью.
Effect комплексно закрывает то, что обычно собирают из кусков: типизированные ошибки, внедрение зависимостей без скрытых синглтонов, безопасное освобождение ресурсов, таймауты и повторы, структурированную конкурентность. Отдельная утилита для повторов этого не сделает — у неё нет общего языка с вашим внедрением зависимостей и схемами данных.
Программа как значение: тип Effect<A, E, R>
В обычном коде вызов функции сразу запускает работу. В Effect вы описываете вычисление как значение, которое ещё не выполнено. Его можно передать дальше, обернуть таймаутом, скомбинировать с другим вычислением и подставить другое окружение — всё до runPromise или NodeRuntime.runMain.
Три параметра типа (в документации и выводе компилятора) читаются так:
| Параметр | Смысл | Аналогия с экспедицией |
|---|---|---|
A |
Успешный результат | Точка назначения |
E |
Ожидаемые ошибки, из которых можно восстановиться | Известные риски в маршруте |
R |
Требуемые сервисы / окружение | Снаряжение и команда |
import { Effect, Schema } from "effect"
class NetworkError extends Schema.TaggedError<NetworkError>()("NetworkError", {
message: Schema.String
}) {}
// Effect<number, NetworkError, never>
const loadScore = Effect.gen(function* () {
const ok = Math.random() > 0.2
if (!ok) {
return yield* new NetworkError({ message: "upstream timeout" })
}
return 42
})
Сравнение с привычными формами:
| Форма | Что видно в типе | Чего не видно |
|---|---|---|
() => User |
Успешный объект | Побочные эффекты, ошибки |
() => Promise<User> |
Асинхронный успех | Полный набор ошибок и зависимостей |
Effect.Effect<User, E, R> |
Успех, ошибки, требования | Гарантии внешнего мира (сеть всё ещё может врать) |
Разделение описания и исполнения помогает тестам: вы собираете тот же граф программы, что и в проде, но подставляете тестовый Layer. Effect не обещает, что внешний API всегда честен — он обещает, что ваш контракт ошибок и зависимостей не спрячется в неявных исключениях.
Типизированные ошибки вместо размытого try/catch
try/catch ловит всё подряд: и «пользователь не найден», и опечатку в коде, и обрыв сокета. Тип функции при этом остаётся «всегда возвращает User», пока кто-то не бросит исключение. Ожидаемые бизнес-ошибки и неожиданные дефекты смешиваются.
В Effect ожидаемые ошибки моделируют как типизированные значения — удобно через Schema.TaggedError — и обрабатывают точечно:
import { Effect, Schema } from "effect"
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
id: Schema.String
}) {}
class ValidationFailed extends Schema.TaggedError<ValidationFailed>()(
"ValidationFailed",
{ message: Schema.String }
) {}
declare const fetchUser: (
id: string
) => Effect.Effect<{ id: string; email: string }, UserNotFound | ValidationFailed>
const program = fetchUser("42").pipe(
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed({ id: e.id, email: "[email protected]" })
),
Effect.catchTag("ValidationFailed", (e) =>
Effect.fail(e) // пробрасываем дальше по архитектуре
)
)
Ошибки проходят через уровни архитектуры как данные: контроллер может превратить UserNotFound в HTTP 404, а ValidationFailed — в 400, без угадывания по тексту Error.message. Повторы и запасные варианты (Effect.retry, политика через Schedule) вешают на те сбои, которые имеет смысл повторять — обычно сеть, а не «запись не найдена».
Практический сюжет: получить пользователя из API. Возможны отсутствие записи, ошибка сети и ошибка проверки тела ответа. В типе это видно вызывающему коду; в Promise<User> — нет.
Асинхронность, конкурентность и отмена
Promise и Effect оба описывают асинхронную работу, но по-разному. Promise стартует при создании; Effect — при запуске рантайма. Promise плохо описывает составные политики (таймаут + повтор + отмена дочерних задач) как единый тип. Effect даёт комбинаторы и модель структурированной конкурентности: жизненный цикл дочерних операций связан с родительской.
Типичные задачи:
- ограничить ожидание (
Effect.timeout); - повторить при временном сбое (
Effect.retry+Schedule); - запустить несколько запросов и дождаться всех или первого;
- прервать работу, когда клиент отменил HTTP-запрос.
При отмене важно не только «перестать ждать», но и освободить ресурсы. Если открыли соединение или файл, финализатор должен отработать. Здесь конкурентность смыкается с управлением ресурсами: отмена — штатный путь, а не «надеемся, что GC закроет сокет».
Гарантии снова честные: Effect упорядочивает ваш код. Если внешний SDK игнорирует AbortSignal, библиотека не сотворит чудо — но вы хотя бы явно видите, где граница ответственности.
Сервисы и Layer: зависимости как часть типа
Глобальный клиент базы или «синглтон на модуле» усложняет тесты: приходится подменять импорты, чистить состояние между кейсами, помнить порядок инициализации. Effect предлагает другой ход: сервис — контракт, реализация — отдельный Layer.
import { Context, Effect, Layer, Schema } from "effect"
class DbError extends Schema.TaggedError<DbError>()("DbError", {
message: Schema.String
}) {}
export class UserRepo extends Context.Service<
UserRepo,
{
findById: (
id: string
) => Effect.Effect<{ id: string; email: string }, DbError>
}
>()("myapp/UserRepo") {
static readonly layer = Layer.effect(
UserRepo,
Effect.gen(function* () {
const findById = Effect.fn("UserRepo.findById")(function* (id: string) {
// живая реализация: SQL, HTTP, кэш…
return { id, email: `${id}@example.com` }
})
return UserRepo.of({ findById })
})
)
static readonly layerTest = Layer.succeed(
UserRepo,
UserRepo.of({
findById: (id) => Effect.succeed({ id, email: "[email protected]" })
})
)
}
const getEmail = Effect.gen(function* () {
const repo = yield* UserRepo
const user = yield* repo.findById("u-1")
return user.email
})
// прод
const live = getEmail.pipe(Effect.provide(UserRepo.layer))
// тест
const test = getEmail.pipe(Effect.provide(UserRepo.layerTest))
Слои компонуют: репозиторий зависит от конфига и HTTP-клиента, сервис пользователей — от репозитория и внешнего API. В типе R видно, чего не хватает, пока вы не предоставили полный граф. Это и есть внедрение зависимостей без скрытой магии контейнера «на строках».
Schema: проверка данных на границах системы
Типы TypeScript стираются при компиляции. JSON от партнёра, тело HTTP-запроса, переменные окружения и ответ LLM — всё это unknown по сути, даже если вы написали as User. Нужна проверка во время выполнения, согласованная с внутренними моделями.
Модуль Schema задаёт структуру, декодирует вход и кодирует выход. Ошибка разбора становится частью того же канала ошибок, что и остальная программа — не отдельным зоопарком исключений Zod в одном углу и try/catch в другом (хотя Zod отлично живёт в проектах без Effect; см. сравнение ниже).
import { Effect, Schema } from "effect"
const UserDto = Schema.Struct({
id: Schema.String,
email: Schema.String
})
type UserDto = typeof UserDto.Type
const parseUser = (input: unknown) =>
Schema.decodeUnknown(UserDto)(input)
const program = Effect.gen(function* () {
const raw: unknown = JSON.parse('{"id":"1","email":"[email protected]"}')
return yield* parseUser(raw)
})
Сценарий из жизни: сервер «чуть поменял» поле или прислал число вместо строки. Схема падает явно на границе; внутрь домена не протекает полуразобранный объект. Тот же подход работает для конфигурации — в духе идеи из материала про валидацию переменных окружения в TypeScript, только встроенный в общую модель Effect.
Ресурсы и короткий взгляд на экосистему
Файлы, пулы соединений, сокеты и подписки нельзя «забыть» при ошибке или отмене. Effect использует Scope и финализаторы (Effect.acquireRelease и связанные API): захват и освобождение связываются так, чтобы выход из области видимости — в том числе по прерыванию — запускал очистку. Это предсказуемее ручного try/finally, который легко обойти ранним return в другом слое или потерять при рефакторинге.
Кратко по соседним модулям — не каталог API, а карта, куда смотреть дальше:
Stream— потоки данных, где медленный потребитель сам сдерживает быстрый источник (ленты событий, постраничная загрузка).Schedule— расписания повторов и периодических задач.- Логи и трассировка — наблюдение за тем же графом эффектов.
- HTTP / SQL / CLI / AI — прикладные пакеты экосистемы v4 с общей версией.
Имеет смысл подключать модуль, когда задача уже есть, а не «потому что в экосистеме красиво».
Сквозной пример: сервис пользователя
Сначала узнаваемый TypeScript на Promise. Затем — тот же сценарий на Effect. Задача: взять пользователя из внешнего API, проверить JSON, уложиться в таймаут, при временном сбое повторить запрос, в тесте подменить сеть.
Обычный TypeScript
type User = { id: string; email: string }
class UserNotFoundError extends Error {
constructor(readonly id: string) {
super(`User ${id} not found`)
}
}
async function fetchUserJson(id: string): Promise<unknown> {
const res = await fetch(`https://api.example.com/users/${id}`)
if (res.status === 404) throw new UserNotFoundError(id)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
function parseUser(data: unknown): User {
if (
typeof data !== "object" ||
data === null ||
typeof (data as User).id !== "string" ||
typeof (data as User).email !== "string"
) {
throw new Error("invalid user payload")
}
return data as User
}
async function getUser(id: string): Promise<User> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 2000)
try {
// упрощённо: повторы, AbortSignal и разбор обычно размазаны по хелперам
const raw = await fetchUserJson(id)
return parseUser(raw)
} finally {
clearTimeout(timer)
}
}
Инфраструктура (таймаут, повтор, проверка, тестовая подмена fetch) быстро расползается. Контракт ошибок читается только из текста исключений.
Тот же сценарий на Effect
import { Context, Effect, Layer, Schedule, Schema } from "effect"
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
id: Schema.String
}) {}
class NetworkError extends Schema.TaggedError<NetworkError>()("NetworkError", {
message: Schema.String
}) {}
const User = Schema.Struct({
id: Schema.String,
email: Schema.String
})
type User = typeof User.Type
export class UserApi extends Context.Service<
UserApi,
{
getById: (
id: string
) => Effect.Effect<User, UserNotFound | NetworkError>
}
>()("myapp/UserApi") {
static readonly layerTest = Layer.succeed(
UserApi,
UserApi.of({
getById: (id) => Effect.succeed({ id, email: "[email protected]" })
})
)
}
const loadUser = Effect.fn("loadUser")(function* (id: string) {
const api = yield* UserApi
return yield* api.getById(id).pipe(
Effect.timeout("2 seconds"),
Effect.retry({ times: 2, schedule: Schedule.exponential("100 millis") }),
Effect.catchTag("TimeoutError", () =>
Effect.fail(new NetworkError({ message: "timeout" }))
)
)
})
const testProgram = loadUser("u-1").pipe(Effect.provide(UserApi.layerTest))
Сравнение по осям из плана:
| Ось | Promise-стиль |
Effect |
|---|---|---|
| Инфраструктурный код | Часто размазан по хелперам | Политики рядом с вычислением |
| Контракты | Слабо видны в типах | E и R в сигнатуре |
| Тестируемость | Моки модулей / глобальный fetch |
Подмена Layer |
| Ошибки | Исключения и строки | Тегированные типы |
| Порог входа | Низкий | Выше: абстракции и новый стиль |
Для учебного CRUD выигрыш Effect может не окупить обучение. Для сервиса с несколькими зависимостями и жёсткими тестами — наоборот.
Как внедрять Effect и с чем его сравнивают
Максимальная польза — в серверных сервисах с множеством внешних зависимостей, где уже болят тесты, повторы и разрозненная обработка ошибок. Внедрять лучше постепенно: новый модуль или граница API на Effect, остальное — на Promise. Мост — ManagedRuntime и явные адаптеры на краю (HTTP-обработчик вызывает runtime.runPromise(program)).
Не каждой небольшой программе нужен Effect. Скрипт на сто строк, витрина с простым CRUD, прототип на выходные — обычный TypeScript будет честнее. Сложность обучения команды реальна: нужны общие правила (как объявлять ошибки, как резать слои, где заканчивается Effect). Без договорённостей библиотека превращается в зоопарк стилей.
| Подход | Основное назначение |
|---|---|
Обычный TypeScript + Promise |
Простой привычный асинхронный код |
fp-ts |
Функциональные абстракции и композиция |
Zod |
Проверка данных и вывод типов схем |
TanStack Query |
Серверное состояние на клиенте |
Effect |
Ошибки, зависимости, ресурсы и конкурентность в одной модели |
Инструменты не всегда конкурируют. TanStack Query в офлайн-сценариях решает задачу UI-кэша и синхронизации — другую, чем Effect на сервере. Zod может остаться на фронте, пока серверная часть переезжает на Schema. fp-ts ближе философски, но Effect целится в прикладную «операционную» полноту (ресурсы, конкурентность, слои).
Рядом по теме контрактов TypeScript: ограничения обобщённых типов, tRPC и общие типы клиента/сервера, долгая жизнь архитектуры.
Стоит ли изучать Effect в 2026 году
Перед стартом полезны уверенный TypeScript (обобщённые типы, объединения, сужение типов), опыт с асинхронным кодом и понимание, зачем вам внедрение зависимостей и проверка на границах. Абстракции сначала непривычны: «программа как значение» и канал R требуют нескольких рабочих дней, не одного руководства.
Имеет смысл пробовать Effect в существующем проекте, если уже есть боль: хрупкие тесты из-за синглтонов, разные стили ретраев, утечки при отмене, дыры на границе JSON. Если боли нет — библиотека станет самоцелью.
Что проще традиционными средствами: тонкие обёртки, одноразовые скрипты, UI-состояние на клиенте (там часто важнее клиентский кэш запросов или хранилище состояния). Оценивайте зрелость экосистемы по релизу v4, единой версии пакетов, документации и активности официального репозитория. Совместимость со стеком проверяйте на маленьком пилоте: один сервис, один тестовый слой, один адаптер к Express/Fastify/Hono — и ретроспектива через две недели.
Частые вопросы
Чем Effect отличается от ещё одной Promise-утилиты?
Утилита обычно закрывает один кусок (повторы или объект результата). Effect связывает ошибки, зависимости, ресурсы и конкурентность в одной модели типов, чтобы политики не жили в разных стилях.
Нужно ли переписывать весь проект?
Нет. Начните с границы: новый модуль, адаптер к HTTP, тестовый Layer. Остальной код может оставаться на Promise, пока пилот не докажет ценность.
Effect вместо Zod?
Не обязательно. Zod отличен для многих кодовых баз. Внутри Effect-кода Schema даёт ту же экосистему ошибок и интеграцию с сервисами. На фронте Zod часто остаётся разумным выбором.
Это то же самое, что fp-ts?
Оба связаны с функциональным стилем, но Effect сильнее заточен под прикладные задачи: слои, ресурсы, конкурентность, единый рантайм. fp-ts — скорее библиотека абстракций; Effect — операционная платформа приложения.
Подойдёт ли Effect для React-приложения?
Для серверной логики и тяжёлых клиентов с сложным доменом — да. Для локального UI-состояния обычно достаточно инструментов экосистемы React. Смешивайте осознанно: Effect на домене, React на отрисовке.
Что такое дефект и чем он отличается от ошибки в канале E?
Ожидаемая ошибка — часть домена (нет сущности, отказ валидации). Дефект — нарушение инварианта или баг. Их обрабатывают разными политиками: первые — восстановлением и HTTP-кодами, вторые — аварийной остановкой и алертом.
Насколько стабилен API в v4?
v4 — сознательный крупный релиз с унификацией версий пакетов и обновлением сервисов. Закрепите версию в проекте и не смешивайте примеры v3 и v4 в одной статье команды.
Дальше по теме
- Валидация переменных окружения в TypeScript
- Ограничения обобщённых типов TypeScript
tRPCв 2026: типобезопасный полный стек- Офлайн в React:
TanStack Queryи IndexedDB - Как проектировать систему на десятилетие
- Современная корпоративная платформа учёта: от монолита к платформе
- 7 скрытых уязвимостей в Node.js-приложениях
Внешние источники: официальная документация, исходный код на GitHub, миграция v3 → v4.
Заключение
Effect полезен не как коллекция функций с короткими именами, а как способ явно описать ошибки, зависимости, ресурсы и конкурентность. Ценность особенно заметна там, где сложность приложения уже не позволяет надёжно держать эти механизмы разрозненными решениями.
На этой неделе: выберите один сервис с внешним API, опишите его ошибки тегами, вынесите клиент в Context.Service, напишите layerTest и прогоните один сценарий без мока глобального fetch. Если команде стало проще читать контракт — экспедиция собрана правильно; если нет, оставьте Promise и зафиксируйте, какой боли ещё не хватило для смены инструмента.



Комментарии