Either<L, R>
Вычисления с двумя возможными результатами.
Теория
Проблема: ошибки без контекста
Option<A> говорит нам только “есть или нет”, но не объясняет почему значения нет. В реальных системах нам нужна информация об ошибке:
Option<User> Either<UserError, User>
┌───────────┐ ┌──────────────────────┐
│ Some(usr) │ │ Right(user) │
│ None │ ← почему? │ Left(NotFound) │
└───────────┘ │ Left(Forbidden) │
│ Left(NetworkErr) │
└──────────────────────┘
Что такое Either
Either<L, R> — это алгебраический тип данных (sum type) с двумя вариантами:
Either<L, R> = Left<L> | Right<R>
┌─────────────────────────────────┐
│ Either<Error, number> │
│ │
│ ┌─────────────┐ ┌───────────┐ │
│ │ Left(err) │ │ Right(42) │ │
│ │ │ │ │ │
│ │ _tag:"Left" │ │_tag:"Right│ │
│ │ left: Error │ │ right: 42 │ │
│ └─────────────┘ └───────────┘ │
└─────────────────────────────────┘
Конвенция по именованию:
Left— традиционно используется для ошибок / альтернативного значенияRight— для успешного результата (“right” = “correct”)
⚠️ В Effect-ts порядок параметров типа: Either<Right, Left> — это отличается от классического Either<Left, Right> в Haskell/fp-ts. Правый (успешный) тип стоит первым.
Effect-ts: Either<R, L> → Either<SuccessType, ErrorType>
Haskell: Either a b → Either ErrorType SuccessType
fp-ts: Either<E, A> → Either ErrorType SuccessType
Концепция ФП
Either как Bifunctor
Either — это бифунктор, позволяющий трансформировать оба канала:
mapBoth:
┌────────────┐ onLeft: L → L2 ┌─────────────┐
│ Left(L) │ ──────────────────► │ Left(L2) │
└────────────┘ └─────────────┘
┌────────────┐ onRight: R → R2 ┌─────────────┐
│ Right(R) │ ──────────────────► │ Right(R2) │
└────────────┘ └─────────────┘
Railway Oriented Programming
Either реализует паттерн “Railway Oriented Programming” — две параллельные “железнодорожные пути”:
Right track (happy path):
══════╦══════╦══════╦══════► Right(result)
║ ║ ║
map flatMap map
║ ║ ║
Left track (error path):
──────╨──────╨──────╨──────► Left(error)
Любой переход на Left track — все последующие
операции на Right track пропускаются
Создание Either
right — успешное значение
const success = Either.right(42)
// { _id: 'Either', _tag: 'Right', right: 42 }
// С явной типизацией ошибки
const typed: Either.Either<number, string> = Either.right(42)
left — ошибочное значение
const failure = Either.left("Something went wrong")
// { _id: 'Either', _tag: 'Left', left: 'Something went wrong' }
const typedError: Either.Either<number, Error> =
Either.left(new Error("DB connection failed"))
try — оборачивание бросающей функции
// Безопасная обёртка для JSON.parse
const safeJsonParse = (input: string): Either.Either<unknown, Error> =>
Either.try({
try: () => JSON.parse(input) as unknown,
catch: (e) => new Error(String(e))
})
console.log(safeJsonParse('{"name":"Alice"}'))
// Right({ name: "Alice" })
console.log(safeJsonParse("invalid"))
// Left(Error: ...)
fromOption — конвертация из Option
const fromOpt = Either.fromOption(
Option.some(42),
() => "Value is missing" // что вернуть в Left если None
)
// Right(42)
const fromNone = Either.fromOption(
Option.none(),
() => "Value is missing"
)
// Left("Value is missing")
API Reference
Guards
const value = Either.right(42)
if (Either.isRight(value)) {
console.log(value.right) // 42 — TypeScript знает, что это Right
}
if (Either.isLeft(value)) {
console.log(value.left) // TypeScript знает, что это Left
}
Either.isEither(value) // true
Either.isEither(42) // false
Pattern matching
const result: Either.Either<number, string> = Either.right(42)
const message = Either.match(result, {
onLeft: (error) => `Ошибка: ${error}`,
onRight: (value) => `Значение: ${value}`
})
console.log(message)
// "Значение: 42"
getRight / getLeft — извлечение как Option
const success = Either.right(42)
Either.getRight(success) // Option.some(42)
Either.getLeft(success) // Option.none()
const failure = Either.left("error")
Either.getRight(failure) // Option.none()
Either.getLeft(failure) // Option.some("error")
Трансформации
map — преобразование Right
// Трансформация только правого (успешного) значения
const doubled = Either.map(Either.right(21), (n) => n * 2)
// Right(42)
// Left проходит без изменений
const unchanged = Either.map(
Either.left("error") as Either.Either<number, string>,
(n) => n * 2
)
// Left("error")
mapLeft — преобразование Left
// Трансформация только левого (ошибочного) значения
const enriched = Either.mapLeft(
Either.left("not found"),
(msg) => `DB Error: ${msg}`
)
// Left("DB Error: not found")
// Right проходит без изменений
const untouched = Either.mapLeft(
Either.right(42) as Either.Either<number, string>,
(msg) => `Error: ${msg}`
)
// Right(42)
mapBoth — преобразование обоих каналов
const transformed = Either.mapBoth(Either.right(21), {
onLeft: (s: string) => new Error(s),
onRight: (n) => n * 2
})
// Right(42)
const transformedError = Either.mapBoth(Either.left("fail"), {
onLeft: (s: string) => new Error(s),
onRight: (n: number) => n * 2
})
// Left(Error("fail"))
flatMap — цепочки вычислений
const parseNumber = (s: string): Either.Either<number, string> => {
const n = Number(s)
return Number.isNaN(n)
? Either.left(`"${s}" is not a number`)
: Either.right(n)
}
const validatePositive = (n: number): Either.Either<number, string> =>
n > 0
? Either.right(n)
: Either.left(`${n} is not positive`)
const validateRange = (n: number): Either.Either<number, string> =>
n <= 100
? Either.right(n)
: Either.left(`${n} exceeds maximum of 100`)
// Цепочка валидаций — первая ошибка прерывает цепочку
const validateInput = (input: string): Either.Either<number, string> =>
pipe(
parseNumber(input),
Either.flatMap(validatePositive),
Either.flatMap(validateRange)
)
console.log(validateInput("42"))
// Right(42)
console.log(validateInput("abc"))
// Left('"abc" is not a number')
console.log(validateInput("-5"))
// Left('-5 is not positive')
console.log(validateInput("200"))
// Left('200 exceeds maximum of 100')
Визуализация цепочки flatMap:
"42" ─► parseNumber ─► Right(42) ─► validatePositive ─► Right(42) ─► validateRange ─► Right(42) ✓
"abc" ─► parseNumber ─► Left("not a number") ─────────────────────────────────────────► Left(...) ✗
"-5" ─► parseNumber ─► Right(-5) ─► validatePositive ─► Left("not positive") ────────► Left(...) ✗
Обработка ошибок
orElse — альтернативное вычисление
const primary: Either.Either<number, string> = Either.left("primary failed")
// Попробовать альтернативу при ошибке
const result = Either.orElse(primary, (error) =>
error === "primary failed"
? Either.right(0) // fallback значение
: Either.left(500)
)
// Right(0)
merge — извлечение значения из обоих каналов
Когда оба канала имеют одинаковый тип:
const success: Either.Either<string, string> = Either.right("ok")
const failure: Either.Either<string, string> = Either.left("error")
Either.merge(success) // "ok"
Either.merge(failure) // "error"
flip — обмен каналов
const original: Either.Either<number, string> = Either.right(42)
const flipped = Either.flip(original)
// Either<string, number> → Left(42)
Интеграция с Effect
Как и Option, Either является подтипом Effect:
Маппинг Either → Effect:
Right<R> → Effect<R, never, never> (успешный эффект)
Left<L> → Effect<never, L, never> (ошибка)
const parse = (input: string): Either.Either<number, string> => {
const n = Number(input)
return Number.isNaN(n) ? Either.left("invalid number") : Either.right(n)
}
// Either можно использовать напрямую в Effect.gen
const program = Effect.gen(function* () {
const value = yield* parse("42") // Either → Effect
return value * 2
})
Effect.runPromise(program).then(console.log)
// 84
// Комбинирование Either с другими Effect
const program = Effect.gen(function* () {
const parsed = yield* parse("42")
const fetched = yield* Effect.succeed("data")
return { parsed, fetched }
})
Either vs Option
┌─────────────────────┬────────────────────┬──────────────────────────┐
│ Критерий │ Option<A> │ Either<R, L> │
├─────────────────────┼────────────────────┼──────────────────────────┤
│ Варианты │ Some | None │ Right | Left │
│ Информация об ошибке│ Нет │ Да (в Left) │
│ Use case │ "Есть или нет" │ "Успех или причина" │
│ Functor │ map на Some │ map на Right │
│ Нулевой элемент │ None │ Зависит от Left │
│ Конвертация │ → Either (fromOpt) │ → Option (getRight) │
│ В Effect │ NoSuchElement │ L как тип ошибки │
└─────────────────────┴────────────────────┴──────────────────────────┘
Когда использовать что:
- Option — для простого наличия/отсутствия, когда причина отсутствия очевидна (поиск в коллекции, парсинг необязательного поля)
- Either — когда важна причина неудачи (валидация, бизнес-логика, API-ответы)
- Effect — когда нужны побочные эффекты, зависимости или асинхронность
Паттерны использования
Типизированные ошибки валидации
// Tagged union для ошибок
type ValidationError =
| { readonly _tag: "EmptyField"; readonly field: string }
| { readonly _tag: "InvalidFormat"; readonly field: string; readonly expected: string }
| { readonly _tag: "OutOfRange"; readonly field: string; readonly min: number; readonly max: number }
const validateEmail = (email: string): Either.Either<string, ValidationError> =>
email.length === 0
? Either.left({ _tag: "EmptyField", field: "email" })
: !email.includes("@")
? Either.left({ _tag: "InvalidFormat", field: "email", expected: "user@domain.com" })
: Either.right(email)
const validateAge = (age: number): Either.Either<number, ValidationError> =>
age < 18 || age > 120
? Either.left({ _tag: "OutOfRange", field: "age", min: 18, max: 120 })
: Either.right(age)
Обработка ошибок по тегу
type AppError =
| { readonly _tag: "NotFound"; readonly id: string }
| { readonly _tag: "Forbidden"; readonly reason: string }
| { readonly _tag: "NetworkError"; readonly code: number }
const handleError = (error: AppError): string =>
error._tag === "NotFound"
? `Resource ${error.id} not found`
: error._tag === "Forbidden"
? `Access denied: ${error.reason}`
: `Network error: code ${error.code}`
const result: Either.Either<string, AppError> =
Either.left({ _tag: "NotFound", id: "user-123" })
const message = Either.match(result, {
onLeft: handleError,
onRight: (data) => `Success: ${data}`
})
Парсинг с детальными ошибками
interface ParsedConfig {
readonly host: string
readonly port: number
readonly secure: boolean
}
const parseConfig = (raw: Record<string, unknown>): Either.Either<ParsedConfig, string> =>
pipe(
Either.all({
host: typeof raw["host"] === "string"
? Either.right(raw["host"] as string)
: Either.left("host must be a string"),
port: typeof raw["port"] === "number" && Number.isInteger(raw["port"])
? Either.right(raw["port"] as number)
: Either.left("port must be an integer"),
secure: typeof raw["secure"] === "boolean"
? Either.right(raw["secure"] as boolean)
: Either.left("secure must be a boolean")
})
)
Упражнения
Упражнение 1: Safe parseInt
Реализуйте функцию безопасного парсинга целого числа:
import { Either } from "effect"
const safeParseInt = (input: string): Either.Either<number, string> => {
// Ваш код
}
// safeParseInt("42") → Right(42)
// safeParseInt("3.14") → Left("3.14 is not an integer")
// safeParseInt("abc") → Left("abc is not a number")import { Either } from "effect"
const safeParseInt = (input: string): Either.Either<number, string> => {
const n = Number(input)
return Number.isNaN(n)
? Either.left(`${input} is not a number`)
: !Number.isInteger(n)
? Either.left(`${input} is not an integer`)
: Either.right(n)
}Упражнение 2: Either.all
Используйте Either.all для комбинирования нескольких Either:
import { Either } from "effect"
// Объедините результаты трёх валидаций
const validateForm = (data: {
readonly name: string
readonly email: string
readonly age: string
}): Either.Either<{ name: string; email: string; age: number }, string> => {
// Ваш код
}import { Either } from "effect"
const validateForm = (data: {
readonly name: string
readonly email: string
readonly age: string
}): Either.Either<{ name: string; email: string; age: number }, string> =>
Either.all({
name: data.name.length >= 2
? Either.right(data.name)
: Either.left("Name must be at least 2 characters"),
email: data.email.includes("@")
? Either.right(data.email)
: Either.left("Invalid email format"),
age: !Number.isNaN(Number(data.age)) && Number(data.age) > 0
? Either.right(Number(data.age))
: Either.left("Age must be a positive number")
})Упражнение 3: Chain of Transformations
Постройте пайплайн преобразований строкового ввода в структуру данных:
import { Either, pipe } from "effect"
interface ParsedUrl {
readonly protocol: "http" | "https"
readonly host: string
readonly port: number
}
const parseUrl = (input: string): Either.Either<ParsedUrl, string> => {
// Ваш код
// Шаги: разделить по "://", извлечь протокол, хост, порт
// "https://example.com:8080" → Right({ protocol: "https", host: "example.com", port: 8080 })
// "ftp://test.com" → Left("Unsupported protocol: ftp")
}import { Either, pipe } from "effect"
interface ParsedUrl {
readonly protocol: "http" | "https"
readonly host: string
readonly port: number
}
const parseProtocol = (input: string): Either.Either<{ protocol: "http" | "https"; rest: string }, string> => {
const parts = input.split("://")
if (parts.length !== 2) return Either.left("Invalid URL format")
const proto = parts[0]!
const rest = parts[1]!
return proto === "http" || proto === "https"
? Either.right({ protocol: proto, rest })
: Either.left(`Unsupported protocol: ${proto}`)
}
const parseHostAndPort = (rest: string): Either.Either<{ host: string; port: number }, string> => {
const parts = rest.split(":")
const host = parts[0] ?? ""
if (host.length === 0) return Either.left("Empty host")
const port = parts.length > 1 ? Number(parts[1]) : 80
return Number.isNaN(port)
? Either.left(`Invalid port: ${parts[1]}`)
: Either.right({ host, port })
}
const parseUrl = (input: string): Either.Either<ParsedUrl, string> =>
pipe(
parseProtocol(input),
Either.flatMap(({ protocol, rest }) =>
pipe(
parseHostAndPort(rest),
Either.map(({ host, port }) => ({ protocol, host, port }))
)
)
)Упражнение 4: Either-based State Machine
Реализуйте конечный автомат с типизированными переходами, где невалидные переходы возвращают Left:
import { Either, pipe } from "effect"
type OrderState = "created" | "paid" | "shipped" | "delivered" | "cancelled"
type OrderEvent =
| { readonly _tag: "Pay" }
| { readonly _tag: "Ship" }
| { readonly _tag: "Deliver" }
| { readonly _tag: "Cancel" }
type TransitionError = {
readonly from: OrderState
readonly event: string
readonly reason: string
}
const transition = (
state: OrderState,
event: OrderEvent
): Either.Either<OrderState, TransitionError> => {
// Ваш код
}
// Реализуйте функцию, которая обрабатывает последовательность событий
const processEvents = (
events: ReadonlyArray<OrderEvent>
): Either.Either<OrderState, TransitionError> => {
// Ваш код
}import { Either, pipe } from "effect"
type OrderState = "created" | "paid" | "shipped" | "delivered" | "cancelled"
type OrderEvent =
| { readonly _tag: "Pay" }
| { readonly _tag: "Ship" }
| { readonly _tag: "Deliver" }
| { readonly _tag: "Cancel" }
type TransitionError = {
readonly from: OrderState
readonly event: string
readonly reason: string
}
const mkError = (from: OrderState, event: string, reason: string): TransitionError =>
({ from, event, reason })
const transition = (
state: OrderState,
event: OrderEvent
): Either.Either<OrderState, TransitionError> => {
switch (event._tag) {
case "Pay":
return state === "created"
? Either.right("paid")
: Either.left(mkError(state, "Pay", "Can only pay from created state"))
case "Ship":
return state === "paid"
? Either.right("shipped")
: Either.left(mkError(state, "Ship", "Can only ship after payment"))
case "Deliver":
return state === "shipped"
? Either.right("delivered")
: Either.left(mkError(state, "Deliver", "Can only deliver after shipping"))
case "Cancel":
return state === "created" || state === "paid"
? Either.right("cancelled")
: Either.left(mkError(state, "Cancel", `Cannot cancel in ${state} state`))
}
}
const processEvents = (
events: ReadonlyArray<OrderEvent>
): Either.Either<OrderState, TransitionError> =>
events.reduce<Either.Either<OrderState, TransitionError>>(
(acc, event) => Either.flatMap(acc, (state) => transition(state, event)),
Either.right("created" as OrderState)
)