Effect Курс Трансформации

Трансформации

Трансформации позволяют преобразовывать Effect-ы, создавая цепочки вычислений.

Теория

Зачем нужны трансформации?

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

┌─────────────────┐                    ┌─────────────────┐
│  Effect<A>      │  ─── map(f) ────►  │  Effect<B>      │
│                 │                    │                 │
│  Исходный       │                    │  Новый Effect   │
│  (неизменён)    │                    │  (результат f)  │
└─────────────────┘                    └─────────────────┘

Классификация трансформаций

ОперацияТипНазначение
map(A => B) => Effect<B>Преобразование результата
flatMap(A => Effect<B>) => Effect<B>Цепочка эффектов
tap(A => Effect<_>) => Effect<A>Побочный эффект
as(B) => Effect<B>Замена результата константой
flattenEffect<Effect<A>> => Effect<A>Развёртывание вложенного
mapError(E1 => E2) => Effect<A, E2>Преобразование ошибки
flatMapError(E1 => Effect<A, E2>) => Effect<A, E2>Recovery с эффектом

Концепция ФП

Functor (map)

map реализует паттерн Functor — применение функции к значению внутри контейнера:


// Functor law: map(id) ≡ id
const identity = <A>(a: A): A => a
const effect = Effect.succeed(42)

// Эти два эффекта эквивалентны
const mapped = Effect.map(effect, identity)
const original = effect

// Functor law: map(f ∘ g) ≡ map(f) ∘ map(g)
const f = (x: number) => x * 2
const g = (x: number) => x + 1

// Эти два эффекта эквивалентны
const composed = Effect.map(effect, (x) => f(g(x)))
const sequential = Effect.map(Effect.map(effect, g), f)

Monad (flatMap)

flatMap реализует паттерн Monad — цепочка зависимых вычислений:


// Monad law: return a >>= f ≡ f a (Left Identity)
const leftIdentity = <A, B>(a: A, f: (a: A) => Effect.Effect<B>) =>
  // Effect.flatMap(Effect.succeed(a), f) ≡ f(a)
  true

// Monad law: m >>= return ≡ m (Right Identity)
const rightIdentity = <A>(m: Effect.Effect<A>) =>
  // Effect.flatMap(m, Effect.succeed) ≡ m
  true

// Monad law: (m >>= f) >>= g ≡ m >>= (x => f x >>= g) (Associativity)
const associativity = <A, B, C>(
  m: Effect.Effect<A>,
  f: (a: A) => Effect.Effect<B>,
  g: (b: B) => Effect.Effect<C>
) =>
  // Effect.flatMap(Effect.flatMap(m, f), g) ≡ 
  // Effect.flatMap(m, (x) => Effect.flatMap(f(x), g))
  true

Отличие map от flatMap


const getValue = Effect.succeed(5)

// map: трансформирует значение, НЕ меняет структуру Effect
const doubled = Effect.map(getValue, (x) => x * 2)
// Effect<number, never, never>

// flatMap: цепочка эффектов, может изменить E и R
const fetchData = (id: number): Effect.Effect<string, Error> =>
  id > 0 
    ? Effect.succeed(`data-${id}`)
    : Effect.fail(new Error("Invalid id"))

const chained = Effect.flatMap(getValue, fetchData)
// Effect<string, Error, never>
// Типы E и R "накапливаются" при композиции

Трансформация результата

Effect.map

Преобразует успешный результат Effect, применяя функцию:


// Сигнатура:
// map: <A, B>(self: Effect<A, E, R>, f: (a: A) => B) => Effect<B, E, R>

const baseEffect = Effect.succeed(10)

// Простое преобразование
const doubled = Effect.map(baseEffect, (x) => x * 2)
// Effect<number, never, never> — результат 20

// Преобразование типа
const asString = Effect.map(baseEffect, (x) => `Value: ${x}`)
// Effect<string, never, never>

// Извлечение поля
interface User {
  readonly id: string
  readonly name: string
}

const userEffect: Effect.Effect<User> = Effect.succeed({ 
  id: "1", 
  name: "John" 
})

const nameEffect = Effect.map(userEffect, (user) => user.name)
// Effect<string, never, never>

// Деструктуризация
const extractId = Effect.map(userEffect, ({ id }) => id)
// Effect<string, never, never>

Effect.mapError

Преобразует ошибку Effect:


// Сигнатура:
// mapError: <A, E, R, E2>(self: Effect<A, E, R>, f: (e: E) => E2) => Effect<A, E2, R>

class LowLevelError extends Data.TaggedError("LowLevelError")<{
  readonly code: number
}> {}

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

const lowLevelEffect: Effect.Effect<string, LowLevelError> = 
  Effect.fail(new LowLevelError({ code: 404 }))

const highLevelEffect: Effect.Effect<string, HighLevelError> = 
  Effect.mapError(lowLevelEffect, (err) => 
    new HighLevelError({ message: `Error code: ${err.code}` })
  )

// Преобразование в стандартный Error
const standardized = Effect.mapError(lowLevelEffect, (err) =>
  new Error(`Low level error: ${err.code}`)
)
// Effect<string, Error, never>

Effect.mapBoth

Преобразует и результат, и ошибку одновременно:


// Сигнатура:
// mapBoth: <A, E, R, A2, E2>(
//   self: Effect<A, E, R>,
//   options: { onSuccess: (a: A) => A2; onFailure: (e: E) => E2 }
// ) => Effect<A2, E2, R>

const original: Effect.Effect<number, string> = Effect.succeed(42)

const transformed = Effect.mapBoth(original, {
  onSuccess: (n) => ({ value: n, timestamp: Date.now() }),
  onFailure: (msg) => new Error(msg)
})
// Effect<{ value: number; timestamp: number }, Error, never>

Последовательная композиция

Effect.flatMap

Создаёт цепочку зависимых эффектов — результат первого передаётся во второй:


// Сигнатура:
// flatMap: <A, E, R, B, E2, R2>(
//   self: Effect<A, E, R>,
//   f: (a: A) => Effect<B, E2, R2>
// ) => Effect<B, E | E2, R | R2>

// Пример: цепочка API вызовов
interface User { readonly id: string; readonly teamId: string }
interface Team { readonly id: string; readonly name: string }

class NotFoundError extends Data.TaggedError("NotFoundError")<{
  readonly entity: string
  readonly id: string
}> {}

const fetchUser = (id: string): Effect.Effect<User, NotFoundError> =>
  Effect.succeed({ id, teamId: "team-1" })

const fetchTeam = (teamId: string): Effect.Effect<Team, NotFoundError> =>
  Effect.succeed({ id: teamId, name: "Engineering" })

// Цепочка: получить user, затем его team
const getUserTeam = (userId: string): Effect.Effect<Team, NotFoundError> =>
  Effect.flatMap(
    fetchUser(userId),
    (user) => fetchTeam(user.teamId)
  )

// Типы ошибок и зависимостей объединяются
class NetworkError extends Data.TaggedError("NetworkError")<{}> {}
class ParseError extends Data.TaggedError("ParseError")<{}> {}

const fetchJson = (url: string): Effect.Effect<unknown, NetworkError> =>
  Effect.succeed({ data: "json" })

const parseData = (json: unknown): Effect.Effect<string, ParseError> =>
  Effect.succeed("parsed")

const fetchAndParse = (url: string): Effect.Effect<string, NetworkError | ParseError> =>
  Effect.flatMap(fetchJson(url), parseData)
// E = NetworkError | ParseError (объединение)

Effect.flatten

Развёртывает вложенный Effect:


// Сигнатура:
// flatten: <A, E, R, E2, R2>(
//   self: Effect<Effect<A, E2, R2>, E, R>
// ) => Effect<A, E | E2, R | R2>

// Вложенный Effect
const nested: Effect.Effect<Effect.Effect<number>> = 
  Effect.succeed(Effect.succeed(42))

// Развёртывание
const flat: Effect.Effect<number> = Effect.flatten(nested)

// Эквивалентно:
const alsoFlat = Effect.flatMap(nested, (inner) => inner)

// Практический пример: условное создание эффекта
const maybeCreateEffect = (condition: boolean) =>
  Effect.succeed(
    condition 
      ? Effect.succeed("created") 
      : Effect.fail(new Error("not created"))
  )

const result = Effect.flatten(maybeCreateEffect(true))
// Effect<string, Error, never>

Effect.andThen

Универсальный комбинатор для цепочек — автоматически выбирает map или flatMap:


// andThen принимает:
// 1. Значение => map
// 2. Функцию A => B => map
// 3. Effect => flatMap (игнорирует результат первого)
// 4. Функцию A => Effect<B> => flatMap

const base = Effect.succeed(5)

// Как map с функцией
const doubled = Effect.andThen(base, (x) => x * 2)
// Effect<number, never, never>

// Как flatMap с функцией, возвращающей Effect
const withEffect = Effect.andThen(base, (x) => 
  Effect.succeed(`Value: ${x}`)
)
// Effect<string, never, never>

// Цепочка эффектов (игнорирует результат первого)
const sequential = Effect.andThen(
  Effect.log("First"),
  Effect.succeed("Second result")
)
// Effect<string, never, never>

Effect.tap

Выполняет побочный эффект, не изменяя результат:


// Сигнатура:
// tap: <A, E, R, X, E2, R2>(
//   self: Effect<A, E, R>,
//   f: (a: A) => Effect<X, E2, R2>
// ) => Effect<A, E | E2, R | R2>

const getValue = Effect.succeed(42)

// Логирование без изменения результата
const withLog = Effect.tap(getValue, (value) =>
  Console.log(`Got value: ${value}`)
)
// Effect<number, never, never> — всё ещё возвращает 42

// Цепочка с tap для отладки
const pipeline = Effect.succeed({ id: 1, name: "Test" }).pipe(
  Effect.tap((data) => Effect.log(`Processing: ${JSON.stringify(data)}`)),
  Effect.map((data) => data.name),
  Effect.tap((name) => Effect.log(`Extracted name: ${name}`)),
  Effect.map((name) => name.toUpperCase())
)
// Effect<string, never, never>

// tap с ошибками — ошибки НЕ игнорируются
const withValidation = Effect.tap(getValue, (value) =>
  value > 100 
    ? Effect.fail(new Error("Value too large"))
    : Effect.void
)
// Effect<number, Error, never>

Побочные эффекты

Effect.tapError

Выполняет эффект при ошибке, не изменяя её:


// Логирование ошибок
const mayFail: Effect.Effect<number, Error> = Effect.fail(new Error("oops"))

const withErrorLog = Effect.tapError(mayFail, (error) =>
  Console.error(`Error occurred: ${error.message}`)
)
// Effect<number, Error, never> — ошибка пробрасывается дальше

Effect.tapDefect

Выполняет эффект при дефекте (unexpected error):


const mayDie = Effect.die("Unexpected!")

const withDefectLog = Effect.tapDefect(mayDie, (cause) =>
  Console.error(`Defect: ${Cause.pretty(cause)}`)
)

Effect.tapBoth

Выполняет эффект и при успехе, и при ошибке:


const operation: Effect.Effect<number, Error> = Effect.succeed(42)

const withBothTap = Effect.tapBoth(operation, {
  onSuccess: (value) => Console.log(`Success: ${value}`),
  onFailure: (error) => Console.error(`Failure: ${error.message}`)
})

Специальные трансформации

Effect.as

Заменяет результат на константу:


// Сигнатура:
// as: <A, E, R, B>(self: Effect<A, E, R>, value: B) => Effect<B, E, R>

const original = Effect.succeed(42)

// Заменяем результат
const asString = Effect.as(original, "done")
// Effect<string, never, never> — результат "done"

// Полезно для "нормализации" типов
const normalize = <E, R>(effect: Effect.Effect<unknown, E, R>) =>
  Effect.as(effect, "completed" as const)

// Часто используется с tap
const operation = Effect.succeed(42).pipe(
  Effect.tap((x) => Effect.log(`Processing ${x}`)),
  Effect.as("processed")
)

Effect.asVoid

Заменяет результат на void:


// Сигнатура:
// asVoid: <A, E, R>(self: Effect<A, E, R>) => Effect<void, E, R>

const withResult = Effect.succeed(42)
const withoutResult = Effect.asVoid(withResult)
// Effect<void, never, never>

// Полезно для побочных эффектов
const sideEffect = Effect.sync(() => {
  console.log("Side effect")
  return Math.random() // Результат не нужен
}).pipe(Effect.asVoid)

Effect.asSome и Effect.asNone

Оборачивают результат в Option:


const value = Effect.succeed(42)

const asSome = Effect.asSome(value)
// Effect<Option<number>, never, never> — Some(42)

const asNone = Effect.asNone
// Effect<Option<never>, never, never> — None

Effect.flip

Меняет местами каналы успеха и ошибки:


// Сигнатура:
// flip: <A, E, R>(self: Effect<A, E, R>) => Effect<E, A, R>

const success: Effect.Effect<number, string> = Effect.succeed(42)
const flipped: Effect.Effect<string, number> = Effect.flip(success)
// При запуске: fail с 42

const failure: Effect.Effect<number, string> = Effect.fail("error")
const flippedFailure: Effect.Effect<string, number> = Effect.flip(failure)
// При запуске: succeed с "error"

// Полезно для тестирования ошибок
const expectError = <E>(effect: Effect.Effect<unknown, E>) =>
  Effect.flip(effect)
// Теперь ошибка в канале успеха — можно проверять

Effect.merge

Объединяет каналы ошибки и успеха:


// Сигнатура:
// merge: <A, E, R>(self: Effect<A, E, R>) => Effect<A | E, never, R>

const mayFail: Effect.Effect<number, string> = Effect.fail("error")

const merged: Effect.Effect<number | string> = Effect.merge(mayFail)
// Результат: "error" (как успех)

// Полезно для обработки в switch-case
const result = await Effect.runPromise(merged)
if (typeof result === "string") {
  console.log("Was error:", result)
} else {
  console.log("Was success:", result)
}

Pipe и композиция

Использование pipe

pipe позволяет строить цепочки трансформаций в читаемом виде:


// Без pipe — вложенные вызовы
const nested = Effect.map(
  Effect.map(
    Effect.succeed(5),
    (x) => x * 2
  ),
  (x) => x + 1
)
// Сложно читать

// С pipe — последовательные шаги
const piped = pipe(
  Effect.succeed(5),
  Effect.map((x) => x * 2),
  Effect.map((x) => x + 1)
)
// Легко читать

// Метод .pipe на Effect
const method = Effect.succeed(5).pipe(
  Effect.map((x) => x * 2),
  Effect.map((x) => x + 1)
)
// Ещё удобнее

Композиция нескольких операций


interface User {
  readonly id: string
  readonly email: string
  readonly role: "admin" | "user"
}

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

class AuthError extends Data.TaggedError("AuthError")<{
  readonly reason: string
}> {}

// Функции-трансформаторы
const validateUser = (user: User): Effect.Effect<User, ValidationError> =>
  user.email.includes("@")
    ? Effect.succeed(user)
    : Effect.fail(new ValidationError({ field: "email", message: "Invalid" }))

const checkAdmin = (user: User): Effect.Effect<User, AuthError> =>
  user.role === "admin"
    ? Effect.succeed(user)
    : Effect.fail(new AuthError({ reason: "Admin required" }))

const formatResponse = (user: User) => ({
  id: user.id,
  displayEmail: user.email.split("@")[0]
})

// Композиция через pipe
const processUser = (user: User) =>
  pipe(
    Effect.succeed(user),
    Effect.flatMap(validateUser),
    Effect.flatMap(checkAdmin),
    Effect.map(formatResponse),
    Effect.tap((result) => Effect.log(`Processed: ${result.id}`))
  )

// Тип: Effect<{ id: string; displayEmail: string }, ValidationError | AuthError, never>

Flow — pre-composed pipelines


// flow создаёт функцию-композицию
const processNumber = flow(
  Effect.succeed<number>,
  Effect.map((x) => x * 2),
  Effect.map((x) => `Result: ${x}`)
)

// Использование
const result = processNumber(21)
// Effect<string, never, never> — "Result: 42"

Примеры

Пример 1: Data Pipeline


// Типы данных
interface RawEvent {
  readonly timestamp: string
  readonly type: string
  readonly payload: string
}

interface ParsedEvent {
  readonly timestamp: Date
  readonly type: "click" | "view" | "purchase"
  readonly data: Record<string, unknown>
}

interface EnrichedEvent extends ParsedEvent {
  readonly sessionId: string
  readonly userId: string
}

// Ошибки
class ParseError extends Data.TaggedError("ParseError")<{
  readonly field: string
  readonly value: string
}> {}

class EnrichmentError extends Data.TaggedError("EnrichmentError")<{
  readonly reason: string
}> {}

// Трансформации
const parseTimestamp = (raw: string): Effect.Effect<Date, ParseError> => {
  const date = new Date(raw)
  return isNaN(date.getTime())
    ? Effect.fail(new ParseError({ field: "timestamp", value: raw }))
    : Effect.succeed(date)
}

const parseType = (raw: string): Effect.Effect<"click" | "view" | "purchase", ParseError> => {
  const valid = ["click", "view", "purchase"] as const
  return valid.includes(raw as typeof valid[number])
    ? Effect.succeed(raw as typeof valid[number])
    : Effect.fail(new ParseError({ field: "type", value: raw }))
}

const parsePayload = (raw: string): Effect.Effect<Record<string, unknown>, ParseError> =>
  Effect.try({
    try: () => JSON.parse(raw) as Record<string, unknown>,
    catch: () => new ParseError({ field: "payload", value: raw })
  })

// Pipeline: Raw -> Parsed
const parseEvent = (raw: RawEvent): Effect.Effect<ParsedEvent, ParseError> =>
  Effect.gen(function* () {
    const timestamp = yield* parseTimestamp(raw.timestamp)
    const type = yield* parseType(raw.type)
    const data = yield* parsePayload(raw.payload)
    
    return { timestamp, type, data }
  })

// Обогащение данными (simulated)
const enrichEvent = (event: ParsedEvent): Effect.Effect<EnrichedEvent, EnrichmentError> =>
  Effect.succeed({
    ...event,
    sessionId: `session-${Date.now()}`,
    userId: `user-${Math.random().toString(36).slice(2)}`
  })

// Полный pipeline
const processEvent = (raw: RawEvent): Effect.Effect<
  EnrichedEvent,
  ParseError | EnrichmentError
> =>
  pipe(
    Effect.succeed(raw),
    Effect.tap((r) => Effect.log(`Processing event: ${r.type}`)),
    Effect.flatMap(parseEvent),
    Effect.tap((p) => Effect.log(`Parsed: ${p.type} at ${p.timestamp}`)),
    Effect.flatMap(enrichEvent),
    Effect.tap((e) => Effect.log(`Enriched: session=${e.sessionId}`))
  )

// Использование
const program = Effect.gen(function* () {
  const raw: RawEvent = {
    timestamp: "2024-01-15T10:30:00Z",
    type: "purchase",
    payload: '{"amount": 99.99, "currency": "USD"}'
  }
  
  const enriched = yield* processEvent(raw)
  yield* Effect.log(`Final: ${JSON.stringify(enriched)}`)
})

Effect.runPromise(program)

Пример 2: Validation Pipeline


// Форма регистрации
interface RegistrationForm {
  readonly email: string
  readonly password: string
  readonly age: string
}

// Валидированные данные
interface ValidatedForm {
  readonly email: string
  readonly password: string
  readonly age: number
}

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

// Валидаторы
const validateEmail = (email: string): Either.Either<string, string> =>
  email.includes("@") && email.includes(".")
    ? Either.right(email.toLowerCase())
    : Either.left("Invalid email format")

const validatePassword = (password: string): Either.Either<string, string> => {
  if (password.length < 8) {
    return Either.left("Password must be at least 8 characters")
  }
  if (!/[A-Z]/.test(password)) {
    return Either.left("Password must contain uppercase letter")
  }
  if (!/[0-9]/.test(password)) {
    return Either.left("Password must contain number")
  }
  return Either.right(password)
}

const validateAge = (age: string): Either.Either<number, string> => {
  const n = parseInt(age, 10)
  if (isNaN(n)) {
    return Either.left("Age must be a number")
  }
  if (n < 18 || n > 120) {
    return Either.left("Age must be between 18 and 120")
  }
  return Either.right(n)
}

// Собираем все ошибки (не fail-fast)
const validateForm = (form: RegistrationForm): Effect.Effect<ValidatedForm, ValidationError> =>
  Effect.gen(function* () {
    const emailResult = validateEmail(form.email)
    const passwordResult = validatePassword(form.password)
    const ageResult = validateAge(form.age)
    
    const errors: Array<{ field: string; message: string }> = []
    
    if (Either.isLeft(emailResult)) {
      errors.push({ field: "email", message: emailResult.left })
    }
    if (Either.isLeft(passwordResult)) {
      errors.push({ field: "password", message: passwordResult.left })
    }
    if (Either.isLeft(ageResult)) {
      errors.push({ field: "age", message: ageResult.left })
    }
    
    if (errors.length > 0) {
      return yield* Effect.fail(new ValidationError({ errors }))
    }
    
    return {
      email: (emailResult as Either.Right<string, string>).right,
      password: (passwordResult as Either.Right<string, string>).right,
      age: (ageResult as Either.Right<number, string>).right
    }
  })

// Pipeline с трансформациями
const registrationPipeline = (form: RegistrationForm) =>
  pipe(
    validateForm(form),
    Effect.tap((valid) => Effect.log(`Validated: ${valid.email}`)),
    Effect.map((valid) => ({
      ...valid,
      email: valid.email,
      passwordHash: `hashed:${valid.password}` // Simulated hash
    })),
    Effect.tap((result) => Effect.log(`Ready to save: ${result.email}`))
  )

// Тест
const program = Effect.gen(function* () {
  const validForm: RegistrationForm = {
    email: "User@Example.COM",
    password: "SecurePass123",
    age: "25"
  }
  
  const result = yield* registrationPipeline(validForm)
  yield* Effect.log(`Success: ${JSON.stringify(result)}`)
})

Effect.runPromise(program)

Пример 3: Error Recovery Pipeline


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

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

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

// Источники данных
const fetchFromPrimary = (id: string): Effect.Effect<string, PrimarySourceError> =>
  id === "valid"
    ? Effect.succeed(`Primary data for ${id}`)
    : Effect.fail(new PrimarySourceError({ url: `/primary/${id}` }))

const fetchFromFallback = (id: string): Effect.Effect<string, FallbackSourceError> =>
  Effect.succeed(`Fallback data for ${id}`)

const fetchFromCache = (id: string): Effect.Effect<Option.Option<string>, CacheError> =>
  Effect.succeed(
    id === "cached" ? Option.some(`Cached data for ${id}`) : Option.none()
  )

// Recovery pipeline
const fetchWithRecovery = (id: string): Effect.Effect<string> =>
  pipe(
    // Сначала пробуем кэш
    fetchFromCache(id),
    Effect.flatMap((cached) =>
      Option.match(cached, {
        onSome: (data) => Effect.succeed(data),
        onNone: () =>
          pipe(
            // Затем primary источник
            fetchFromPrimary(id),
            Effect.tap(() => Effect.log("Fetched from primary")),
            // При ошибке — fallback
            Effect.catchTag("PrimarySourceError", (error) =>
              pipe(
                Effect.log(`Primary failed: ${error.url}, trying fallback`),
                Effect.flatMap(() => fetchFromFallback(id)),
                Effect.tap(() => Effect.log("Fetched from fallback"))
              )
            )
          )
      })
    ),
    // Финальная обработка ошибки кэша
    Effect.catchTag("CacheError", () =>
      pipe(
        fetchFromPrimary(id),
        Effect.catchTag("PrimarySourceError", () => fetchFromFallback(id))
      )
    )
  )

// Тест
const program = Effect.gen(function* () {
  yield* Effect.log("=== Testing valid ID ===")
  const valid = yield* fetchWithRecovery("valid")
  yield* Effect.log(`Result: ${valid}`)
  
  yield* Effect.log("\n=== Testing invalid ID ===")
  const invalid = yield* fetchWithRecovery("invalid")
  yield* Effect.log(`Result: ${invalid}`)
  
  yield* Effect.log("\n=== Testing cached ID ===")
  const cached = yield* fetchWithRecovery("cached")
  yield* Effect.log(`Result: ${cached}`)
})

Effect.runPromise(program)

Упражнения

Basic

Упражнение 4.1: Базовые трансформации

Используя только map, flatMap и tap, реализуйте pipeline:


// Дано
const getNumber: Effect.Effect<number> = Effect.succeed(10)

// Реализуйте pipeline, который:
// 1. Умножает число на 2
// 2. Логирует промежуточный результат
// 3. Преобразует в строку "Result: X"
// 4. Возвращает длину строки

const pipeline: Effect.Effect<number> = /* ??? */

Решение


const getNumber: Effect.Effect<number> = Effect.succeed(10)

const pipeline: Effect.Effect<number> = pipe(
  getNumber,
  Effect.map((n) => n * 2),                           // 20
  Effect.tap((n) => Effect.log(`Intermediate: ${n}`)), // log
  Effect.map((n) => `Result: ${n}`),                   // "Result: 20"
  Effect.map((s) => s.length)                          // 10
)

Effect.runPromise(pipeline).then(console.log) // 10

Упражнение 4.2: Преобразование ошибок

Преобразуйте низкоуровневые ошибки в доменные:


// Низкоуровневые ошибки
class NetworkError extends Data.TaggedError("NetworkError")<{
  readonly code: number
}> {}

class TimeoutError extends Data.TaggedError("TimeoutError")<{
  readonly ms: number
}> {}

// Доменная ошибка
class ApiError extends Data.TaggedError("ApiError")<{
  readonly message: string
  readonly recoverable: boolean
}> {}

// Эффект с низкоуровневой ошибкой
const lowLevelEffect: Effect.Effect<string, NetworkError | TimeoutError> =
  Effect.fail(new NetworkError({ code: 503 }))

// Преобразуйте в ApiError
const domainEffect: Effect.Effect<string, ApiError> = /* ??? */

Решение


class NetworkError extends Data.TaggedError("NetworkError")<{
  readonly code: number
}> {}

class TimeoutError extends Data.TaggedError("TimeoutError")<{
  readonly ms: number
}> {}

class ApiError extends Data.TaggedError("ApiError")<{
  readonly message: string
  readonly recoverable: boolean
}> {}

const lowLevelEffect: Effect.Effect<string, NetworkError | TimeoutError> =
  Effect.fail(new NetworkError({ code: 503 }))

const domainEffect: Effect.Effect<string, ApiError> = Effect.mapError(
  lowLevelEffect,
  (error) => {
    switch (error._tag) {
      case "NetworkError":
        return new ApiError({
          message: `Network error: ${error.code}`,
          recoverable: error.code >= 500 // 5xx recoverable
        })
      case "TimeoutError":
        return new ApiError({
          message: `Request timed out after ${error.ms}ms`,
          recoverable: true
        })
    }
  }
)

Intermediate

Упражнение 4.3: Цепочка с зависимостями

Создайте pipeline из трёх шагов, где каждый зависит от предыдущего:


interface Product { readonly id: string; readonly price: number }
interface Discount { readonly productId: string; readonly percent: number }
interface Order { readonly product: Product; readonly finalPrice: number }

class ProductNotFoundError extends Data.TaggedError("ProductNotFoundError")<{
  readonly id: string
}> {}

class DiscountError extends Data.TaggedError("DiscountError")<{
  readonly productId: string
}> {}

// Реализуйте функции:
const getProduct = (id: string): Effect.Effect<Product, ProductNotFoundError> => /* ??? */

const getDiscount = (productId: string): Effect.Effect<Discount, DiscountError> => /* ??? */

const createOrder = (product: Product, discount: Discount): Effect.Effect<Order> => /* ??? */

// И объедините в pipeline:
const orderPipeline = (productId: string): Effect.Effect<
  Order,
  ProductNotFoundError | DiscountError
> => /* ??? */

Решение


interface Product { readonly id: string; readonly price: number }
interface Discount { readonly productId: string; readonly percent: number }
interface Order { readonly product: Product; readonly finalPrice: number }

class ProductNotFoundError extends Data.TaggedError("ProductNotFoundError")<{
  readonly id: string
}> {}

class DiscountError extends Data.TaggedError("DiscountError")<{
  readonly productId: string
}> {}

// Mock implementations
const products: Record<string, Product> = {
  "p1": { id: "p1", price: 100 },
  "p2": { id: "p2", price: 200 }
}

const discounts: Record<string, Discount> = {
  "p1": { productId: "p1", percent: 10 },
  "p2": { productId: "p2", percent: 20 }
}

const getProduct = (id: string): Effect.Effect<Product, ProductNotFoundError> =>
  products[id]
    ? Effect.succeed(products[id])
    : Effect.fail(new ProductNotFoundError({ id }))

const getDiscount = (productId: string): Effect.Effect<Discount, DiscountError> =>
  discounts[productId]
    ? Effect.succeed(discounts[productId])
    : Effect.fail(new DiscountError({ productId }))

const createOrder = (product: Product, discount: Discount): Effect.Effect<Order> =>
  Effect.succeed({
    product,
    finalPrice: product.price * (1 - discount.percent / 100)
  })

// Pipeline
const orderPipeline = (productId: string): Effect.Effect<
  Order,
  ProductNotFoundError | DiscountError
> =>
  pipe(
    getProduct(productId),
    Effect.tap((p) => Effect.log(`Found product: ${p.id}, price: ${p.price}`)),
    Effect.flatMap((product) =>
      pipe(
        getDiscount(product.id),
        Effect.tap((d) => Effect.log(`Found discount: ${d.percent}%`)),
        Effect.flatMap((discount) => createOrder(product, discount))
      )
    ),
    Effect.tap((order) => Effect.log(`Order created: final price ${order.finalPrice}`))
  )

// Тест
Effect.runPromise(orderPipeline("p1")).then(console.log)
// { product: { id: 'p1', price: 100 }, finalPrice: 90 }

Advanced

Упражнение 4.4: Композируемые валидаторы

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


// Тип валидатора
type Validator<A, B> = (input: A) => Effect.Effect<B, ValidationError>

class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly errors: ReadonlyArray<{ path: string; message: string }>
}> {}

// Реализуйте:

// 1. Комбинатор для объединения валидаторов (все ошибки собираются)
const combine: <A, B, C>(
  v1: Validator<A, B>,
  v2: Validator<A, C>
) => Validator<A, B & C> = /* ??? */

// 2. Комбинатор для цепочки (ошибки первого, затем второго)
const chain: <A, B, C>(
  v1: Validator<A, B>,
  v2: Validator<B, C>
) => Validator<A, C> = /* ??? */

// 3. Базовые валидаторы
const required: <A>(path: string) => Validator<A | null | undefined, A> = /* ??? */
const minLength: (path: string, min: number) => Validator<string, string> = /* ??? */
const isEmail: (path: string) => Validator<string, string> = /* ??? */

// 4. Применение
interface UserInput {
  readonly email: string | null
  readonly password: string | null
}

interface ValidUser {
  readonly email: string
  readonly password: string
}

const validateUser: Validator<UserInput, ValidUser> = /* ??? */

Решение


type Validator<A, B> = (input: A) => Effect.Effect<B, ValidationError>

class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly errors: ReadonlyArray<{ path: string; message: string }>
}> {}

// Объединение ошибок
const mergeErrors = (
  e1: ValidationError,
  e2: ValidationError
): ValidationError =>
  new ValidationError({ errors: [...e1.errors, ...e2.errors] })

// 1. Combine — собирает все ошибки
const combine = <A, B, C>(
  v1: Validator<A, B>,
  v2: Validator<A, C>
): Validator<A, B & C> =>
  (input) =>
    Effect.gen(function* () {
      const exit1 = yield* Effect.either(v1(input))
      const exit2 = yield* Effect.either(v2(input))
      
      const errors: Array<{ path: string; message: string }> = []
      
      if (exit1._tag === "Left") {
        errors.push(...exit1.left.errors)
      }
      if (exit2._tag === "Left") {
        errors.push(...exit2.left.errors)
      }
      
      if (errors.length > 0) {
        return yield* Effect.fail(new ValidationError({ errors }))
      }
      
      return {
        ...(exit1 as { _tag: "Right"; right: B }).right,
        ...(exit2 as { _tag: "Right"; right: C }).right
      } as B & C
    })

// 2. Chain — последовательная валидация
const chain = <A, B, C>(
  v1: Validator<A, B>,
  v2: Validator<B, C>
): Validator<A, C> =>
  (input) => pipe(v1(input), Effect.flatMap(v2))

// 3. Базовые валидаторы
const required = <A>(path: string): Validator<A | null | undefined, A> =>
  (input) =>
    input !== null && input !== undefined
      ? Effect.succeed(input as A)
      : Effect.fail(new ValidationError({ 
          errors: [{ path, message: "Required field is missing" }] 
        }))

const minLength = (path: string, min: number): Validator<string, string> =>
  (input) =>
    input.length >= min
      ? Effect.succeed(input)
      : Effect.fail(new ValidationError({
          errors: [{ path, message: `Minimum length is ${min}` }]
        }))

const isEmail = (path: string): Validator<string, string> =>
  (input) =>
    input.includes("@") && input.includes(".")
      ? Effect.succeed(input)
      : Effect.fail(new ValidationError({
          errors: [{ path, message: "Invalid email format" }]
        }))

// 4. Составной валидатор
interface UserInput {
  readonly email: string | null
  readonly password: string | null
}

interface ValidUser {
  readonly email: string
  readonly password: string
}

const validateUser: Validator<UserInput, ValidUser> = (input) =>
  combine(
    (i: UserInput) => pipe(
      required<string>("email")(i.email),
      Effect.flatMap(isEmail("email"))
    )(input).pipe(Effect.map((email) => ({ email }))),
    (i: UserInput) => pipe(
      required<string>("password")(i.password),
      Effect.flatMap(minLength("password", 8))
    )(input).pipe(Effect.map((password) => ({ password })))
  )(input) as Effect.Effect<ValidUser, ValidationError>

// Тест
const testValidation = Effect.gen(function* () {
  // Valid input
  const valid = yield* validateUser({
    email: "test@example.com",
    password: "securepassword"
  })
  yield* Effect.log(`Valid: ${JSON.stringify(valid)}`)
  
  // Invalid input — собирает все ошибки
  const invalid = yield* Effect.either(
    validateUser({ email: null, password: "short" })
  )
  if (invalid._tag === "Left") {
    yield* Effect.log(`Errors: ${JSON.stringify(invalid.left.errors)}`)
  }
})

Effect.runPromise(testValidation)

Ключевые выводы

  1. map — преобразует успешный результат, не меняя структуру Effect
  2. flatMap — создаёт цепочки зависимых эффектов, объединяет типы E и R
  3. tap — выполняет побочный эффект, сохраняя исходный результат
  4. as/asVoid — заменяют результат на константу
  5. mapError — преобразует типизированные ошибки
  6. pipe — строит читаемые цепочки трансформаций
  7. Монадические законы гарантируют предсказуемое поведение композиции