Effect Курс Анатомия Effect

Анатомия Effect

Центральный тип библиотеки Effect-ts — Effect<A, E, R> — представляет собой ленивое описание вычисления.

Теория

Что такое Effect?

Effect — это описание вычисления, а не само вычисление. Это фундаментальное отличие от императивного программирования, где код выполняется немедленно при вызове функции.

         ┌─── Тип успешного результата (Success)
         │        ┌─── Тип ожидаемой ошибки (Error)
         │        │      ┌─── Требуемые зависимости (Requirements)
         ▼        ▼      ▼
Effect<Success, Error, Requirements>

Ключевые характеристики Effect

Ленивость (Laziness)

Effect не выполняется при создании. Вычисление происходит только при явном запуске через Runtime:


// Это НЕ выполняет вычисление — только создаёт описание
const description = Effect.sync(() => {
  console.log("Выполняюсь!")
  return 42
})

// Никакого вывода в консоль!
// Вычисление произойдёт только при явном запуске

Иммутабельность (Immutability)

Каждая операция над Effect возвращает новый Effect, не изменяя исходный:


const original = Effect.succeed(10)
const doubled = Effect.map(original, (x) => x * 2)
const tripled = Effect.map(original, (x) => x * 3)

// original остаётся неизменным
// doubled и tripled — независимые вычисления

Композиционность (Composability)

Effect-ы можно комбинировать, создавая сложные workflow из простых блоков:


const workflow = pipe(
  Effect.succeed(5),
  Effect.map((x) => x * 2),
  Effect.flatMap((x) => Effect.succeed(x + 1)),
  Effect.map((x) => `Result: ${x}`)
)

Концепция ФП

Effect как функциональный IO

В функциональном программировании существует проблема: чистые функции не могут выполнять побочные эффекты (I/O, мутации, исключения). Решение — отложенные вычисления.

Концептуально Effect<A, E, R> можно представить как функцию:

type Effect<A, E, R> = (context: Context<R>) => E | A

Однако реальная реализация значительно сложнее и поддерживает:

  • Синхронные и асинхронные вычисления
  • Конкурентность и параллелизм
  • Управление ресурсами
  • Структурированную конкурентность через Fiber

Referential Transparency

Effect сохраняет референциальную прозрачность — описание вычисления всегда можно заменить на само описание без изменения семантики программы:


// Эти два варианта семантически эквивалентны
const program1 = Effect.flatMap(
  Effect.succeed(5),
  (x) => Effect.succeed(x * 2)
)

const five = Effect.succeed(5)
const program2 = Effect.flatMap(five, (x) => Effect.succeed(x * 2))

Связь с Monad

Effect реализует паттерн Monad из теории категорий:

ОперацияEffect APIМонадическая операция
return / pureEffect.succeedОборачивание значения
bind / >>=Effect.flatMapЦепочка вычислений
mapEffect.mapТрансформация результата

// Монадические законы выполняются:

// 1. Left Identity: return a >>= f ≡ f a
const leftIdentity = Effect.flatMap(Effect.succeed(5), (x) => Effect.succeed(x * 2))
// эквивалентно Effect.succeed(10)

// 2. Right Identity: m >>= return ≡ m
const rightIdentity = Effect.flatMap(Effect.succeed(5), Effect.succeed)
// эквивалентно Effect.succeed(5)

// 3. Associativity: (m >>= f) >>= g ≡ m >>= (x => f x >>= g)

Анатомия типа Effect

Визуализация типа

┌────────────────────────────────────────────────────────────────┐
│                    Effect<A, E, R>                             │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │   Success    │  │    Error     │  │    Requirements      │  │
│  │     (A)      │  │     (E)      │  │        (R)           │  │
│  ├──────────────┤  ├──────────────┤  ├──────────────────────┤  │
│  │ Тип значения │  │ Ожидаемые    │  │ Зависимости для      │  │
│  │ при успехе   │  │ ошибки       │  │ выполнения           │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
│                                                                │
│  Порядок параметров: A, E, R (Success, Error, Requirements)    │
│                                                                │
└────────────────────────────────────────────────────────────────┘

Почему такой порядок параметров?

Effect использует порядок <A, E, R> (а не <R, E, A> как в некоторых других библиотеках) по нескольким причинам:

  1. Частичное применение типов — Success (A) меняется чаще всего при композиции
  2. Удобство чтения — при map и flatMap фокус на результате
  3. TypeScript inference — компилятор лучше выводит типы в таком порядке

Type Parameters в деталях

Success (A) — Тип успешного результата


// A = number
const numberEffect: Effect.Effect<number, never, never> =
  Effect.succeed(42)

// A = string
const stringEffect: Effect.Effect<string, never, never> =
  Effect.succeed("hello")

// A = void — эффект не возвращает полезного значения
const voidEffect: Effect.Effect<void, never, never> =
  Effect.sync(() => console.log("side effect"))

// A = never — эффект никогда не завершается успешно (бесконечный цикл)
const foreverEffect: Effect.Effect<never, never, never> =
  Effect.forever(Effect.sync(() => {}))

Error (E) — Тип ожидаемой ошибки


// Определяем типизированные ошибки через Data.TaggedError
class NetworkError extends Data.TaggedError("NetworkError")<{
  readonly url: string
  readonly status: number
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly field: string
  readonly message: string
}> {}

// E = NetworkError
const networkCall: Effect.Effect<string, NetworkError, never> =
  Effect.fail(new NetworkError({ url: "/api", status: 500 }))

// E = ValidationError
const validation: Effect.Effect<number, ValidationError, never> =
  Effect.fail(new ValidationError({ field: "email", message: "Invalid format" }))

// E = never — эффект не может завершиться ожидаемой ошибкой
const infallible: Effect.Effect<number, never, never> =
  Effect.succeed(42)

// E = NetworkError | ValidationError — объединение ошибок
const combined: Effect.Effect<string, NetworkError | ValidationError, never> =
  Effect.gen(function* () {
    const data = yield* networkCall
    const parsed = yield* validation
    return `${data}: ${parsed}`
  })

⚠️ Важно: E = never означает, что Effect не может завершиться ожидаемой ошибкой. Это не гарантирует отсутствие дефектов (unexpected errors).

Requirements (R) — Требуемые зависимости


// Определяем сервис через Context.Tag
class Database extends Context.Tag("Database")<
  Database,
  {
    readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>
  }
>() {}

class Logger extends Context.Tag("Logger")<
  Logger,
  {
    readonly log: (message: string) => Effect.Effect<void>
  }
>() {}

// R = Database
const dbEffect: Effect.Effect<ReadonlyArray<unknown>, never, Database> =
  Effect.gen(function* () {
    const db = yield* Database
    return yield* db.query("SELECT * FROM users")
  })

// R = Logger
const logEffect: Effect.Effect<void, never, Logger> =
  Effect.gen(function* () {
    const logger = yield* Logger
    yield* logger.log("Application started")
  })

// R = Database | Logger — требуются оба сервиса
const combined: Effect.Effect<void, never, Database | Logger> =
  Effect.gen(function* () {
    const db = yield* Database
    const logger = yield* Logger
    const users = yield* db.query("SELECT * FROM users")
    yield* logger.log(`Found ${users.length} users`)
  })

// R = never — эффект не требует зависимостей
const standalone: Effect.Effect<number, never, never> =
  Effect.succeed(42)

Извлечение типов

Utility Types

Effect предоставляет утилитарные типы для извлечения компонентов из типа Effect:


class Config extends Context.Tag("Config")<Config, { readonly port: number }>() {}

// Исходный эффект
declare const program: Effect.Effect<string, Error, Config>

// Извлечение типа успеха
type Success = Effect.Effect.Success<typeof program>
// ^? type Success = string

// Извлечение типа ошибки
type Err = Effect.Effect.Error<typeof program>
// ^? type Err = Error

// Извлечение типа требований
type Ctx = Effect.Effect.Context<typeof program>
// ^? type Ctx = Config

Практический пример извлечения типов


// Определяем доменные типы
interface User {
  readonly id: string
  readonly name: string
  readonly email: string
}

class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
  readonly userId: string
}> {}

class DatabaseError extends Data.TaggedError("DatabaseError")<{
  readonly cause: unknown
}> {}

class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    readonly findById: (id: string) => Effect.Effect<User, UserNotFoundError | DatabaseError>
  }
>() {}

// Функция, возвращающая Effect
const getUser = (id: string): Effect.Effect<
  User,
  UserNotFoundError | DatabaseError,
  UserRepository
> =>
  Effect.gen(function* () {
    const repo = yield* UserRepository
    return yield* repo.findById(id)
  })

// Извлекаем типы для переиспользования
type GetUserEffect = ReturnType<typeof getUser>
type GetUserSuccess = Effect.Effect.Success<GetUserEffect>      // User
type GetUserError = Effect.Effect.Error<GetUserEffect>          // UserNotFoundError | DatabaseError
type GetUserContext = Effect.Effect.Context<GetUserEffect>      // UserRepository

// Используем извлечённые типы
const processUser = (user: GetUserSuccess): string =>
  `User: ${user.name} <${user.email}>`

const handleError = (error: GetUserError): string => {
  switch (error._tag) {
    case "UserNotFoundError":
      return `User ${error.userId} not found`
    case "DatabaseError":
      return `Database error: ${error.cause}`
  }
}

Сравнение с Promise

Effect vs Promise

ХарактеристикаPromiseEffect
ВыполнениеEager (немедленное)Lazy (отложенное)
Типизация ошибокНет (unknown)Да (E)
ЗависимостиНетДа (R)
ОтменаAbortControllerВстроенная (Interruption)
RetryРучная реализацияВстроенный Schedule
КонкурентностьPromise.all/raceFiber, structured concurrency
Композицияthen/catchmap/flatMap/catchAll
ТестированиеMock модулейПодмена через Context

Пример: одна и та же логика

// ═══════════════════════════════════════════════════════════════
// Promise-based код
// ═══════════════════════════════════════════════════════════════

async function fetchUserPromise(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)  // Тип ошибки теряется
  }
  return response.json()
}

async function mainPromise(): Promise<void> {
  try {
    const user = await fetchUserPromise("123")
    console.log(user)
  } catch (error) {
    // error: unknown — компилятор не знает тип
    console.error("Error:", error)
  }
}

// ═══════════════════════════════════════════════════════════════
// Effect-based код
// ═══════════════════════════════════════════════════════════════


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

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

class ParseError extends Data.TaggedError("ParseError")<{
  readonly cause: unknown
}> {}

const fetchUserEffect = (id: string): Effect.Effect<User, HttpError | ParseError> =>
  Effect.gen(function* () {
    const response = yield* Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`),
      catch: (cause) => new ParseError({ cause })
    })

    if (!response.ok) {
      return yield* Effect.fail(new HttpError({
        status: response.status,
        url: `/api/users/${id}`
      }))
    }

    return yield* Effect.tryPromise({
      try: () => response.json() as Promise<User>,
      catch: (cause) => new ParseError({ cause })
    })
  })

const mainEffect: Effect.Effect<void, HttpError | ParseError> =
  Effect.gen(function* () {
    const user = yield* fetchUserEffect("123")
    yield* Effect.log(`User: ${user.name}`)
  })

// Типы ошибок известны на этапе компиляции!
// mainEffect: Effect.Effect<void, HttpError | ParseError, never>

Примеры

Пример 1: Базовые Effect с разными типами


// ═══════════════════════════════════════════════════════════════
// Effect<A, never, never> — успешный эффект без ошибок и зависимостей
// ═══════════════════════════════════════════════════════════════

const pureValue: Effect.Effect<number, never, never> =
  Effect.succeed(42)

const computation: Effect.Effect<number, never, never> =
  Effect.sync(() => Math.random() * 100)

// ═══════════════════════════════════════════════════════════════
// Effect<A, E, never> — эффект с типизированной ошибкой
// ═══════════════════════════════════════════════════════════════

class DivisionByZero extends Data.TaggedError("DivisionByZero")<{
  readonly dividend: number
}> {}

const safeDivide = (a: number, b: number): Effect.Effect<number, DivisionByZero> =>
  b === 0
    ? Effect.fail(new DivisionByZero({ dividend: a }))
    : Effect.succeed(a / b)

// ═══════════════════════════════════════════════════════════════
// Effect<A, never, R> — эффект с зависимостью
// ═══════════════════════════════════════════════════════════════

class RandomService extends Context.Tag("RandomService")<
  RandomService,
  { readonly nextInt: (max: number) => Effect.Effect<number> }
>() {}

const rollDice: Effect.Effect<number, never, RandomService> =
  Effect.gen(function* () {
    const random = yield* RandomService
    return yield* random.nextInt(6)
  })

// ═══════════════════════════════════════════════════════════════
// Effect<A, E, R> — полный тип со всеми параметрами
// ═══════════════════════════════════════════════════════════════

class Cache extends Context.Tag("Cache")<
  Cache,
  {
    readonly get: (key: string) => Effect.Effect<string | null>
    readonly set: (key: string, value: string) => Effect.Effect<void>
  }
>() {}

class CacheError extends Data.TaggedError("CacheError")<{
  readonly operation: "get" | "set"
  readonly key: string
}> {}

const cachedComputation = (key: string): Effect.Effect<string, CacheError, Cache> =>
  Effect.gen(function* () {
    const cache = yield* Cache
    const cached = yield* cache.get(key)

    if (cached !== null) {
      return cached
    }

    const computed = `computed-${Date.now()}`
    yield* cache.set(key, computed)
    return computed
  })

Пример 2: Трансформация типов через композицию


// Исходный эффект
const base: Effect.Effect<number, never, never> = Effect.succeed(10)

// map изменяет только A
const mapped: Effect.Effect<string, never, never> = pipe(
  base,
  Effect.map((n) => `Value: ${n}`)
)

// mapError изменяет только E
const original: Effect.Effect<number, Error, never> =
  Effect.fail(new Error("oops"))

const remapped: Effect.Effect<number, string, never> = pipe(
  original,
  Effect.mapError((e) => e.message)
)

// flatMap может изменить A и добавить E/R

class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly info: (msg: string) => Effect.Effect<void> }
>() {}

class LogError extends Data.TaggedError("LogError")<{}> {}

const withLogging: Effect.Effect<string, LogError, Logger> = pipe(
  Effect.succeed(42),
  Effect.flatMap((n) =>
    Effect.gen(function* () {
      const logger = yield* Logger
      yield* logger.info(`Processing: ${n}`)
      return `Result: ${n}`
    })
  )
)
// Результат: Effect<string, LogError, Logger>

Пример 3: Реальный сценарий — User Service


// ═══════════════════════════════════════════════════════════════
// Доменные типы
// ═══════════════════════════════════════════════════════════════

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

interface CreateUserInput {
  readonly email: string
  readonly name: string
}

// ═══════════════════════════════════════════════════════════════
// Типизированные ошибки
// ═══════════════════════════════════════════════════════════════

class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
  readonly userId: string
}> {}

class DuplicateEmailError extends Data.TaggedError("DuplicateEmailError")<{
  readonly email: string
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly field: string
  readonly message: string
}> {}

type UserServiceError = UserNotFoundError | DuplicateEmailError | ValidationError

// ═══════════════════════════════════════════════════════════════
// Сервис зависимости
// ═══════════════════════════════════════════════════════════════

class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    readonly findById: (id: string) => Effect.Effect<User | null>
    readonly findByEmail: (email: string) => Effect.Effect<User | null>
    readonly create: (input: CreateUserInput) => Effect.Effect<User>
  }
>() {}

// ═══════════════════════════════════════════════════════════════
// Бизнес-логика с полной типизацией
// ═══════════════════════════════════════════════════════════════

const validateEmail = (email: string): Effect.Effect<string, ValidationError> =>
  email.includes("@")
    ? Effect.succeed(email)
    : Effect.fail(new ValidationError({
        field: "email",
        message: "Invalid email format"
      }))

const getUser = (id: string): Effect.Effect<User, UserNotFoundError, UserRepository> =>
  Effect.gen(function* () {
    const repo = yield* UserRepository
    const user = yield* repo.findById(id)

    if (user === null) {
      return yield* Effect.fail(new UserNotFoundError({ userId: id }))
    }

    return user
  })

const createUser = (input: CreateUserInput): Effect.Effect<
  User,
  DuplicateEmailError | ValidationError,
  UserRepository
> =>
  Effect.gen(function* () {
    // Валидация
    yield* validateEmail(input.email)

    // Проверка уникальности
    const repo = yield* UserRepository
    const existing = yield* repo.findByEmail(input.email)

    if (existing !== null) {
      return yield* Effect.fail(new DuplicateEmailError({ email: input.email }))
    }

    // Создание
    return yield* repo.create(input)
  })

// Тип полностью выведен компилятором:
// createUser: (input: CreateUserInput) => Effect<User, DuplicateEmailError | ValidationError, UserRepository>

// ═══════════════════════════════════════════════════════════════
// Реализация репозитория для тестов
// ═══════════════════════════════════════════════════════════════

const TestUserRepository = Layer.succeed(
  UserRepository,
  {
    findById: (id) => Effect.succeed(
      id === "1" ? { id: "1", email: "test@example.com", name: "Test" } : null
    ),
    findByEmail: (email) => Effect.succeed(
      email === "test@example.com" ? { id: "1", email, name: "Test" } : null
    ),
    create: (input) => Effect.succeed({
      id: crypto.randomUUID(),
      ...input
    })
  }
)

// ═══════════════════════════════════════════════════════════════
// Запуск программы
// ═══════════════════════════════════════════════════════════════

const program = Effect.gen(function* () {
  // Попытка создать пользователя с существующим email
  const result = yield* pipe(
    createUser({ email: "new@example.com", name: "New User" }),
    Effect.catchTag("DuplicateEmailError", (e) =>
      Effect.succeed({ id: "fallback", email: e.email, name: "Fallback" })
    ),
    Effect.catchTag("ValidationError", (e) =>
      Effect.fail(new Error(`Validation failed: ${e.message}`))
    )
  )

  return result
})

// Запуск с предоставлением зависимостей
const runnable = pipe(
  program,
  Effect.provide(TestUserRepository)
)

Effect.runPromise(runnable).then(console.log)

Упражнения

Упражнение

Упражнение 1.1: Определение типов

Легко

Определите типы для следующих сценариев:

import { Effect, Context, Data } from "effect"

// 1. Эффект, который возвращает число и никогда не падает
type Ex1 = Effect.Effect</* ??? */>

// 2. Эффект, который может вернуть строку или упасть с Error
type Ex2 = Effect.Effect</* ??? */>

// 3. Эффект без полезного результата (только побочные эффекты)
type Ex3 = Effect.Effect</* ??? */>

// 4. Эффект, требующий Logger и возвращающий boolean
class Logger extends Context.Tag("Logger")<Logger, { log: (m: string) => Effect.Effect<void> }>() {}
type Ex4 = Effect.Effect</* ??? */>
Упражнение

Упражнение 1.2: Извлечение типов

Легко

Используя utility types, извлеките Success, Error и Context из данного эффекта:

import { Effect, Context, Data } from "effect"

class DbError extends Data.TaggedError("DbError")<{ cause: unknown }> {}
class AuthService extends Context.Tag("AuthService")<AuthService, { verify: () => Effect.Effect<boolean> }>() {}

declare const authenticate: Effect.Effect<string, DbError, AuthService>

// Извлеките типы:
type SuccessType = /* ??? */
type ErrorType = /* ??? */
type ContextType = /* ??? */
Упражнение

Упражнение 1.3: Моделирование доменных ошибок

Средне

Создайте модель для сервиса обработки платежей с типизированными ошибками:

import { Effect, Context, Data } from "effect"

// Требуется реализовать:
// 1. Интерфейс Payment
// 2. Три типа ошибок: InsufficientFunds, InvalidCard, PaymentGatewayError
// 3. Сервис PaymentGateway с методом processPayment
// 4. Функцию processPayment, которая использует сервис

// interface Payment { ... }
// class InsufficientFunds extends Data.TaggedError(...) { ... }
// class InvalidCard extends Data.TaggedError(...) { ... }
// class PaymentGatewayError extends Data.TaggedError(...) { ... }
// class PaymentGateway extends Context.Tag(...) { ... }
// const processPayment = (payment: Payment): Effect.Effect<...> => ...
Упражнение

Упражнение 1.4: Композиция сервисов

Сложно

Реализуйте сложный workflow с несколькими зависимостями:

import { Effect, Context, Data, Layer, pipe } from "effect"

// Задание:
// 1. Создайте три сервиса: UserService, EmailService, AuditService
// 2. Создайте функцию registerUser, которая:
//    - Создаёт пользователя через UserService
//    - Отправляет welcome email через EmailService
//    - Логирует событие через AuditService
// 3. Определите все возможные ошибки
// 4. Создайте тестовые Layer-ы для всех сервисов
// 5. Запустите программу

// Подсказка: финальный тип должен быть примерно таким:
// Effect<User, UserError | EmailError | AuditError, UserService | EmailService | AuditService>