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 — проверка типа
| Функция | Сигнатура | Описание |
|---|---|---|
isSome | Option<A> → boolean | true если Some |
isNone | Option<A> → boolean | true если None |
isOption | unknown → boolean | true если 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)import { Option } from "effect"
const safeDivide = (a: number, b: number): Option.Option<number> =>
b === 0 ? Option.none() : Option.some(a / b)Упражнение 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) → Noneimport { Option } from "effect"
const safeGet = <A>(
arr: ReadonlyArray<A>,
index: number
): Option.Option<A> =>
index >= 0 && index < arr.length
? Option.some(arr[index]!)
: Option.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"
}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 =>
pipe(
config.database,
Option.map((db) => {
const auth = pipe(
db.credentials,
Option.map((creds) => {
const pass = Option.getOrElse(creds.password, () => "")
return `${creds.username}:${pass}@`
}),
Option.getOrElse(() => "")
)
return `${auth}${db.host}:${db.port}`
}),
Option.getOrElse(() => "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
}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 validateEmail = (raw: string | null | undefined): Option.Option<string> =>
pipe(
Option.fromNullable(raw),
Option.filter((s) => s.includes("@") && s.length > 3)
)
const validateAge = (raw: string | null | undefined): Option.Option<number> =>
pipe(
Option.fromNullable(raw),
Option.map(Number),
Option.filter((n) => !Number.isNaN(n) && n >= 18 && n <= 120)
)
const validateName = (raw: string | null | undefined): Option.Option<string> =>
pipe(
Option.fromNullable(raw),
Option.filter((s) => s.trim().length >= 2),
Option.map((s) => s.trim())
)
const validateUser = (input: RawInput): Option.Option<ValidatedUser> =>
Option.all({
email: validateEmail(input.email),
age: validateAge(input.age),
name: validateName(input.name)
})