← Усі статті

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: "[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-запит.

Під час скасування важливо не лише «перестати чекати», а й звільнити ресурси. Якщо відкрито з’єднання чи файл, фіналізатор мусить спрацювати. Тут конкурентність сходиться з керуванням ресурсами: скасування — штатний шлях, а не надія, що збирач сміття закриє сокет.

Гарантії знову чесні: Effect упорядковує ваш код. Якщо зовнішній SDK ігнорує AbortSignal, бібліотека дива не вчинить — але ви принаймні чітко бачите, де закінчується ваша відповідальність.

Сервіси та шари: залежності як частина типу

Глобальний клієнт бази чи «синглтон на модулі» ускладнює тести: доводиться підміняти імпорти, чистити стан між кейсами, пам’ятати порядок ініціалізації. 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-запиту, змінні оточення, відповідь мовної моделі — усе це по суті 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 в одному командному документі.

Висновок

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

Цього тижня: оберіть один сервіс із зовнішнім API, опишіть його помилки тегами, винесіть клієнт у Context.Service, напишіть layerTest і проженіть один сценарій без моку глобального fetch. Якщо команді стало легше читати договір — експедицію зібрано правильно; якщо ні, залиште Promise і зафіксуйте, якого болю ще бракувало для зміни інструмента.

Коментарі

Завантаження коментарів…