Effect Курс Either<L, R>

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")
Упражнение

Упражнение 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> => {
  // Ваш код
}
Упражнение

Упражнение 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")
}
Упражнение

Упражнение 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> => {
  // Ваш код
}