Effect Курс Option<A>

Option<A>

Безопасная работа с отсутствующими значениями.

Теория

Проблема null и undefined

В JavaScript отсутствие значения представлено двумя способами — null и undefined. Оба порождают серьёзные проблемы:

Проблемы null/undefined в TypeScript:

  ┌─────────────────────────────────────────────────┐
  │  const user = getUser(id)                       │
  │                                                 │
  │  user.name  // 💥 TypeError: Cannot read        │
  │             //    property 'name' of null       │
  │                                                 │
  │  Причины:                                       │
  │  1. null !== undefined — два разных "ничего"    │
  │  2. typeof null === "object" — исторический баг │
  │  3. Нет принудительной проверки компилятором    │
  │  4. Ошибки обнаруживаются только в runtime      │
  └─────────────────────────────────────────────────┘

Даже с strictNullChecks в TypeScript проблема решается лишь частично — разработчик должен помнить о проверках, и нет стандартизированного способа композиции операций над nullable-значениями.

Что такое Option

Option<A> — это алгебраический тип данных (ADT), представляющий значение, которое может присутствовать или отсутствовать:

Option<A> = Some<A> | None

  ┌──────────────────────────────┐
  │        Option<number>        │
  │                              │
  │   ┌──────────┐ ┌──────────┐  │
  │   │ Some(42) │ │   None   │  │
  │   │          │ │          │  │
  │   │ _tag:    │ │ _tag:    │  │
  │   │ "Some"   │ │ "None"   │  │
  │   │ value:42 │ │          │  │
  │   └──────────┘ └──────────┘  │
  └──────────────────────────────┘

Ключевые свойства:

  • Type-safe — компилятор заставляет обрабатывать оба случая
  • Composable — можно выстраивать цепочки трансформаций без проверок на null
  • Иммутабельный — значение Option нельзя изменить после создания
  • Referentially transparent — одинаковый вход → одинаковый выход

Зачем использовать Option вместо null

┌─────────────────────────┬────────────────────────────┐
│     null/undefined      │        Option<A>           │
├─────────────────────────┼────────────────────────────┤
│ Два "пустых" значения   │ Одно: None                 │
│ Runtime ошибки          │ Compile-time проверки      │
│ Ручные if-проверки      │ Декларативный API          │
│ Нет композиции          │ map, flatMap, pipe         │
│ typeof null = "object"  │ Строгая типизация _tag     │
│ Легко забыть проверку   │ Паттерн-матчинг            │
└─────────────────────────┴────────────────────────────┘

Концепция ФП

Option как Functor и Monad

Option реализует несколько важных абстракций функционального программирования:

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

map(f: A → B): Option<A> → Option<B>

  Some(42) ──map(x => x * 2)──► Some(84)
  None     ──map(x => x * 2)──► None

Monad — позволяет чейнить операции, которые сами возвращают Option, через flatMap:

flatMap(f: A → Option<B>): Option<A> → Option<B>

  Some(42) ──flatMap(safeDivide(_, 2))──► Some(21)
  Some(42) ──flatMap(safeDivide(_, 0))──► None
  None     ──flatMap(safeDivide(_, 2))──► None

Связь с Maybe в Haskell

Option<A> в Effect — это аналог Maybe a в Haskell:

Haskell              Effect-ts
─────────            ──────────
Just 42              Option.some(42)
Nothing              Option.none()
fmap f (Just x)      Option.map(option, f)
(>>=)                Option.flatMap
maybe def f          Option.match({onNone, onSome})

Создание Option

some — значение присутствует


// Создание Option со значением
const value = Option.some(42)
// { _id: 'Option', _tag: 'Some', value: 42 }

// Типизация
const name: Option.Option<string> = Option.some("Alice")
const scores: Option.Option<ReadonlyArray<number>> = Option.some([95, 87, 92])

none — значение отсутствует


// Создание пустого Option
const empty = Option.none()
// { _id: 'Option', _tag: 'None' }

// С явным указанием типа
const noUser: Option.Option<string> = Option.none()

liftPredicate — условное создание

Option.liftPredicate создаёт Option на основе предиката. Если условие выполняется — Some(value), иначе — None:


const isPositive = (n: number): boolean => n > 0

// Создаём функцию, которая возвращает Option
const parsePositive = Option.liftPredicate(isPositive)

console.log(parsePositive(42))
// { _id: 'Option', _tag: 'Some', value: 42 }

console.log(parsePositive(-1))
// { _id: 'Option', _tag: 'None' }

С type guard для сужения типа:


type Shape = { readonly kind: "circle"; readonly radius: number }
         | { readonly kind: "square"; readonly side: number }

const isCircle = (s: Shape): s is Extract<Shape, { readonly kind: "circle" }> =>
  s.kind === "circle"

const asCircle = Option.liftPredicate(isCircle)

const shape: Shape = { kind: "circle", radius: 10 }
const result = asCircle(shape)
// Option<{ kind: "circle"; radius: number }> — тип сужен!

fromNullable — конвертация из nullable


console.log(Option.fromNullable(null))
// { _id: 'Option', _tag: 'None' }

console.log(Option.fromNullable(undefined))
// { _id: 'Option', _tag: 'None' }

console.log(Option.fromNullable(42))
// { _id: 'Option', _tag: 'Some', value: 42 }

console.log(Option.fromNullable(""))
// { _id: 'Option', _tag: 'Some', value: '' }
// Пустая строка — это валидное значение!

API Reference

Guards — проверка типа

ФункцияСигнатураОписание
isSomeOption<A> → booleantrue если Some
isNoneOption<A> → booleantrue если None
isOptionunknown → booleantrue если Option

const opt = Option.some(42)

if (Option.isSome(opt)) {
  // TypeScript знает, что opt — это Some<number>
  console.log(opt.value) // 42 — безопасный доступ
}

if (Option.isNone(opt)) {
  // TypeScript знает, что opt — это None
  console.log("empty")
}

Pattern matching

Option.match — обрабатывает оба случая и возвращает единый результат:


const greet = (name: Option.Option<string>): string =>
  Option.match(name, {
    onNone: () => "Hello, stranger!",
    onSome: (n) => `Hello, ${n}!`
  })

console.log(greet(Option.some("Alice")))
// "Hello, Alice!"

console.log(greet(Option.none()))
// "Hello, stranger!"

Извлечение значений

getOrElse — значение по умолчанию


// Возвращает значение из Some или default из None
console.log(Option.getOrElse(Option.some(42), () => 0))
// 42

console.log(Option.getOrElse(Option.none(), () => 0))
// 0

getOrThrow — извлечение с выбросом исключения


console.log(Option.getOrThrow(Option.some(42)))
// 42

// Option.getOrThrow(Option.none())
// throws: Error: getOrThrow called on a None

⚠️ Используйте getOrThrow только на границах системы, где вы уверены в наличии значения.

getOrNull / getOrUndefined — для interop


// Конвертация обратно в nullable для взаимодействия с внешним кодом
Option.getOrNull(Option.some(5))       // 5
Option.getOrNull(Option.none())        // null

Option.getOrUndefined(Option.some(5))  // 5
Option.getOrUndefined(Option.none())   // undefined

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

map — преобразование значения

Option.map применяет функцию к значению внутри Some, не затрагивая None:


const double = (n: number): number => n * 2

// Через pipe
const result = pipe(
  Option.some(21),
  Option.map(double)
)
// { _id: 'Option', _tag: 'Some', value: 42 }

// None проходит насквозь
const empty = pipe(
  Option.none() as Option.Option<number>,
  Option.map(double)
)
// { _id: 'Option', _tag: 'None' }

flatMap — цепочки с вложенными Option

Option.flatMap позволяет чейнить операции, которые сами могут вернуть None:


interface Address {
  readonly city: string
  readonly street: Option.Option<string>
}

interface User {
  readonly id: number
  readonly username: string
  readonly address: Option.Option<Address>
}

const user: User = {
  id: 1,
  username: "john_doe",
  address: Option.some({
    city: "New York",
    street: Option.some("123 Main St")
  })
}

// Безопасная цепочка доступа к вложенным Option
const street = pipe(
  user.address,
  Option.flatMap((address) => address.street)
)

console.log(street)
// { _id: 'Option', _tag: 'Some', value: '123 Main St' }
Визуализация flatMap:

  user.address          flatMap(a => a.street)    Результат
  ┌──────────────┐     ┌─────────────────────┐   ┌──────────────────┐
  │ Some(Address) │ ──► │ Address.street       │ = │ Some("123 Main") │
  └──────────────┘     │ = Some("123 Main")   │   └──────────────────┘
                       └─────────────────────┘

  ┌──────────────┐                                ┌──────────────────┐
  │     None     │ ──────────────────────────────► │       None       │
  └──────────────┘     (функция не вызывается)     └──────────────────┘

filter — фильтрация по предикату


// Оставляет значение только если предикат выполняется
const keepPositive = pipe(
  Option.some(42),
  Option.filter((n) => n > 0)
)
// Some(42)

const rejectNegative = pipe(
  Option.some(-5),
  Option.filter((n) => n > 0)
)
// None

// Удаление пустых строк
const removeEmpty = (input: Option.Option<string>): Option.Option<string> =>
  Option.filter(input, (value) => value !== "")

tap — побочный эффект без изменения значения


const result = pipe(
  Option.some(42),
  Option.tap((value) => {
    console.log(`Значение: ${value}`)
    return Option.some(undefined) // возвращаем Option для продолжения цепочки
  })
)
// Лог: "Значение: 42"
// Результат: Some(42) — значение не изменилось

Комбинирование Option

zipWith — объединение двух Option


const sum = Option.zipWith(
  Option.some(2),
  Option.some(3),
  (a, b) => a + b
)
console.log(sum)
// { _id: 'Option', _tag: 'Some', value: 5 }

// Если хотя бы одно None — результат None
const partial = Option.zipWith(
  Option.some(2),
  Option.none() as Option.Option<number>,
  (a, b) => a + b
)
console.log(partial)
// { _id: 'Option', _tag: 'None' }

all — объединение множества Option


// Все Some → tuple из значений
const allPresent = Option.all([
  Option.some(1),
  Option.some("hello"),
  Option.some(true)
] as const)
// Some([1, "hello", true])

// Хотя бы одно None → None
const hasNone = Option.all([
  Option.some(1),
  Option.none(),
  Option.some(true)
])
// None

// Работает и с объектами
const struct = Option.all({
  name: Option.some("Alice"),
  age: Option.some(30)
})
// Some({ name: "Alice", age: 30 })

orElse — фолбэки


const primary = (): Option.Option<number> => Option.none()
const fallback = (): Option.Option<number> => Option.some(42)

// Если primary вернул None — попробовать fallback
const result = primary().pipe(
  Option.orElse(() => fallback())
)
// Some(42)

firstSomeOf — первое непустое значение


const first = Option.firstSomeOf([
  Option.none(),
  Option.none(),
  Option.some(42),
  Option.some(100) // не будет использовано
])

console.log(first)
// { _id: 'Option', _tag: 'Some', value: 42 }

Интеграция с nullable-типами

Конвертация из nullable


// Безопасная обёртка DOM API
const findElement = (id: string): Option.Option<HTMLElement> =>
  Option.fromNullable(document.getElementById(id))

// Безопасная обёртка Map.get
const safeGet = <K, V>(map: ReadonlyMap<K, V>, key: K): Option.Option<V> =>
  Option.fromNullable(map.get(key))

Конвертация обратно в nullable


// Для передачи в сторонний API, ожидающий null
const toNullable = <A>(opt: Option.Option<A>): A | null =>
  Option.getOrNull(opt)

// Для JSON-совместимости
const toJSON = <A>(opt: Option.Option<A>): A | undefined =>
  Option.getOrUndefined(opt)

Интеграция с Effect

Option является подтипом Effect — его можно использовать напрямую в Effect-выражениях:

Маппинг Option → Effect:

  Some<A>  →  Effect<A, never, never>         (успешный эффект)
  None     →  Effect<never, NoSuchElementException>  (ошибка)

// Безопасная функция, возвращающая Option
const head = <A>(array: ReadonlyArray<A>): Option.Option<A> =>
  array.length > 0 ? Option.some(array[0]!) : Option.none()

// Использование Option внутри Effect.gen
const program = Effect.gen(function* () {
  const first = yield* head([1, 2, 3])  // Option → Effect автоматически
  return first * 2
})

Effect.runPromise(program).then(console.log)
// 2

// Комбинирование Option и Effect в Effect.all
const fetchData = (): Effect.Effect<string, string> =>
  Effect.succeed("data from API")

const head = <A>(arr: ReadonlyArray<A>): Option.Option<A> =>
  arr.length > 0 ? Option.some(arr[0]!) : Option.none()

const program = Effect.all([head([1, 2, 3]), fetchData()])

Effect.runPromise(program).then(console.log)
// [1, "data from API"]

Паттерны использования

Моделирование необязательных свойств


// Вместо: email?: string | null | undefined
interface User {
  readonly id: number
  readonly username: string
  readonly email: Option.Option<string>
  readonly phone: Option.Option<string>
}

const withEmail: User = {
  id: 1,
  username: "alice",
  email: Option.some("alice@example.com"),
  phone: Option.none()
}

const withoutEmail: User = {
  id: 2,
  username: "bob",
  email: Option.none(),
  phone: Option.some("+1234567890")
}

💡 Обратите внимание: ключ email всегда присутствует в объекте. Опциональность относится к значению, а не к ключу.

Безопасный доступ к коллекциям


// Безопасное получение элемента массива
const safeHead = <A>(arr: ReadonlyArray<A>): Option.Option<A> =>
  arr.length > 0 ? Option.some(arr[0]!) : Option.none()

const safeLast = <A>(arr: ReadonlyArray<A>): Option.Option<A> =>
  arr.length > 0 ? Option.some(arr[arr.length - 1]!) : Option.none()

// Цепочка безопасных операций
const getFirstUserEmail = (users: ReadonlyArray<User>): string =>
  pipe(
    safeHead(users),
    Option.flatMap((user) => user.email),
    Option.getOrElse(() => "no email found")
  )

Паттерн “safe chain”


interface Config {
  readonly database: Option.Option<{
    readonly host: string
    readonly port: Option.Option<number>
  }>
}

const getDbPort = (config: Config): number =>
  pipe(
    config.database,
    Option.flatMap((db) => db.port),
    Option.getOrElse(() => 5432) // порт по умолчанию
  )

Сортировка с Option


const items = [
  Option.some(3),
  Option.none(),
  Option.some(1),
  Option.some(2)
]

const sorted = Array.sort(items, Option.getOrder(Order.number))
// [None, Some(1), Some(2), Some(3)]
// None считается "наименьшим" значением

Equivalence — проверка равенства Option


const eq = Option.getEquivalence(
  (a: number, b: number) => a === b
)

console.log(eq(Option.some(1), Option.some(1))) // true
console.log(eq(Option.some(1), Option.some(2))) // false
console.log(eq(Option.none(), Option.none()))    // true
console.log(eq(Option.some(1), Option.none()))   // false

Упражнения

Упражнение

Упражнение 1: Safe Division

Легко

Реализуйте функцию безопасного деления, возвращающую None при делении на ноль:

import { Option } from "effect"

const safeDivide = (a: number, b: number): Option.Option<number> => {
  // Ваш код
}

// Тесты:
// safeDivide(10, 2) → Some(5)
// safeDivide(10, 0) → None
// safeDivide(0, 5)  → Some(0)
Упражнение

Упражнение 2: Safe Array Access

Легко

Реализуйте функцию safeGet, которая безопасно получает элемент массива по индексу:

import { Option } from "effect"

const safeGet = <A>(
  arr: ReadonlyArray<A>,
  index: number
): Option.Option<A> => {
  // Ваш код
}

// Тесты:
// safeGet([1, 2, 3], 0)  → Some(1)
// safeGet([1, 2, 3], 5)  → None
// safeGet([1, 2, 3], -1) → None
// safeGet([], 0)          → None
Упражнение

Упражнение 3: Nested Config Access

Средне

Дана структура конфигурации с вложенными Option. Реализуйте функцию безопасного извлечения значения connectionString:

import { Option, pipe } from "effect"

interface DbConfig {
  readonly host: string
  readonly port: number
  readonly credentials: Option.Option<{
    readonly username: string
    readonly password: Option.Option<string>
  }>
}

interface AppConfig {
  readonly database: Option.Option<DbConfig>
}

const getConnectionString = (config: AppConfig): string => {
  // Ваш код
  // Формат: "user:pass@host:port" или "host:port" или "localhost:5432"
}
Упражнение

Упражнение 4: Option-based Validation Pipeline

Сложно

Создайте пайплайн валидации, который проверяет входные данные и возвращает Option с обработанным результатом:

import { Option, pipe } from "effect"

interface RawInput {
  readonly email: string | null | undefined
  readonly age: string | null | undefined
  readonly name: string | null | undefined
}

interface ValidatedUser {
  readonly email: string
  readonly age: number
  readonly name: string
}

const validateUser = (input: RawInput): Option.Option<ValidatedUser> => {
  // Ваш код
  // email: не null, не undefined, содержит @
  // age: не null, не undefined, парсится в число 18-120
  // name: не null, не undefined, длина >= 2
}