Трансформации
Трансформации позволяют преобразовывать 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> | Замена результата константой |
flatten | Effect<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)
Ключевые выводы
- map — преобразует успешный результат, не меняя структуру Effect
- flatMap — создаёт цепочки зависимых эффектов, объединяет типы E и R
- tap — выполняет побочный эффект, сохраняя исходный результат
- as/asVoid — заменяют результат на константу
- mapError — преобразует типизированные ошибки
- pipe — строит читаемые цепочки трансформаций
- Монадические законы гарантируют предсказуемое поведение композиции