← Все статьи

Effect для TypeScript: архитектура, ошибки, зависимости и конкурентность

Как Effect делает ошибки, зависимости и конкурентность частью типов: слои, схемы, сравнение с Promise и Zod — и когда библиотека уже избыточна.

Effect для TypeScript: архитектура, ошибки, зависимости и конкурентность
Содержание

Вы открываете функцию 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: "missing@example.com" })
  ),
  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: "test@example.com" })
    })
  )
}

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":"a@b.c"}')
  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: "test@example.com" })
    })
  )
}

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 в одной статье команды.

Заключение

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

На этой неделе: выберите один сервис с внешним API, опишите его ошибки тегами, вынесите клиент в Context.Service, напишите layerTest и прогоните один сценарий без мока глобального fetch. Если команде стало проще читать контракт — экспедиция собрана правильно; если нет, оставьте Promise и зафиксируйте, какой боли ещё не хватило для смены инструмента.

Комментарии

Загрузка комментариев…