Обработка ошибок в ФП: Option, Either, Result, накопление ошибок
Любая программа, которая делает что-то полезное, большую часть кода тратит не на счастливый путь. Разбор ввода, поход в базу, вызов чужого HTTP, проверка бизнес-правил — каждый шаг может не сработать, и вопрос «что делать, когда не сработало» определяет архитектуру сильнее, чем выбор фреймворка.
ФП отвечает на этот вопрос одним принципом: отказ — это значение, а не событие. Не выпрыгивание из потока управления, не сигнал, не глобальный errno, а обычное данное, которое функция возвращает наравне с успехом. Из этого принципа вырастает всё остальное — Option, Either, Result, Validation, railway-oriented programming и умение отличать «пользователь ввёл чушь» от «в коде баг».
В этой статье мы соберём всю картину: от боли, которую создают исключения, до практических вопросов вроде «где именно ловить чужие исключения», «как не утонуть в разрастающемся типе ошибки» и «сколько миллисекунд стоит Result в горячем цикле». Предполагается, что вы уже прошли алгебраические типы (https://courses.digitable.life/post/functional-programming/06-adt-and-pattern-matching/), функторы (https://courses.digitable.life/post/functional-programming/07-functors-and-applicatives/) и монады (https://courses.digitable.life/post/functional-programming/08-monads/) — здесь они наконец начинают приносить пользу, за которую их и терпят.
Сначала боль: три способа сообщить об ошибке, и все три текут
Исключения: невидимая вторая сигнатура
Посмотрите на функцию и скажите, что она может вернуть:
def load_user_config(user_id: int) -> Config:
raw = db.fetch_config(user_id) # может кинуть ConnectionError, Timeout
parsed = json.loads(raw) # может кинуть JSONDecodeError
return Config(**parsed) # может кинуть TypeError
Тип говорит: «вернёт Config». Правда: вернёт Config или одно из полудюжины исключений, часть из которых прилетит из библиотеки, которую вы не читали. Это вторая, невидимая сигнатура функции — она есть, но её нет ни в типе, ни в автодополнении, ни в code review.
Отсюда три системные беды:
- Забыть обработать — бесплатно. Компилятор ничего не скажет. Программа упадёт в проде, а не на этапе сборки.
- Обработка происходит не там, где надо. Исключение всплывает до первого попавшегося
try, а этотtryчасто написан человеком, который понятия не имел проJSONDecodeError— и написалexcept Exception: log.warning(...), проглотив реальный баг. - Композиция ломается. Функцию, которая кидает, нельзя честно передать в
map, в pipeline, в параллельныйTask— на каждой границе приходится городить обёртку.
Checked exceptions в Java пытались починить пункт 1, добавив throws в сигнатуру. Идея была правильной, реализация — нет: нельзя параметризовать throws по типу, из-за чего дженерики и лямбды с ними не дружат, и индустрия ответила catch (Exception e) { throw new RuntimeException(e); }. Подробный разбор того, почему модели ошибок ломаются именно так, есть в эссе Джо Даффи The Error Model — это лучший текст на тему, который вообще написан.
null: миллиардная ошибка
const user = findUser(id); // User | null, но тип говорит User
console.log(user.email); // 💥 когда-нибудь
Тони Хоар, придумавший null-ссылку в ALGOL W в 1965 году, публично назвал её «ошибкой на миллиард долларов». Проблема не в самом «отсутствии значения» — оно нужно постоянно. Проблема в том, что null входит в каждый тип по умолчанию: любая ссылка может оказаться пустой, и проверить это компилятор без вашей помощи не может.
Коды возврата: правильный ход, ручная дисциплина
int rc = do_thing(&out);
if (rc != 0) { /* обработать */ }
Здесь ошибка честно является значением — это уже ФП-подход в зачаточной форме. Но: результат и признак ошибки разъехались по разным каналам (out и rc), проигнорировать rc можно молча, а сцепить десять таких вызовов означает написать десять if-ов и лестницу goto cleanup. Go унаследовал этот подход в более безопасном виде, но if err != nil каждые три строки — известная плата.
Общий диагноз: во всех трёх случаях информация о возможном отказе живёт отдельно от типа результата. ФП делает ровно одно движение — засовывает её внутрь.
Option/Maybe: «значения может не быть» становится частью типа
Первый и самый простой инструмент решает узкую задачу: функция иногда не может вернуть значение, и это нормально, не ошибка. Поиск в словаре, первый элемент списка, необязательное поле конфигурации.
data Maybe a = Nothing | Just a
Это сумма-тип из двух конструкторов (https://courses.digitable.life/post/functional-programming/06-adt-and-pattern-matching/). Ключевое свойство: Maybe User и User — разные типы. Нельзя случайно обратиться к полю: компилятор потребует сначала разобрать случай.
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
const none: Option<never> = { kind: "none" };
// map — применить функцию внутри, если значение есть
const map = <A, B>(o: Option<A>, f: (a: A) => B): Option<B> =>
o.kind === "some" ? some(f(o.value)) : none;
// flatMap — когда сама функция может вернуть пустоту
const flatMap = <A, B>(o: Option<A>, f: (a: A) => Option<B>): Option<B> =>
o.kind === "some" ? f(o.value) : none;
// выход наружу: единственное место, где мы обязаны решить, что делать с пустотой
const getOrElse = <A>(o: Option<A>, fallback: A): A =>
o.kind === "some" ? o.value : fallback;
Практическая ценность видна на цепочке. Без Option — лестница проверок:
function cityOf(userId: string): string {
const user = findUser(userId);
if (user === null) return "неизвестно";
const addr = user.address;
if (addr === null) return "неизвестно";
const city = addr.city;
if (city === null) return "неизвестно";
return city.toUpperCase();
}
С Option — линейный конвейер, где «пустота» протаскивается сама:
const cityOf = (userId: string): string =>
getOrElse(
map(
flatMap(flatMap(findUser(userId), userAddress), addressCity),
(c) => c.toUpperCase()
),
"неизвестно"
);
Ровно эту же работу в мейнстрим-языках делает optional chaining — user?.address?.city в TypeScript, ?. в C# и Kotlin. Это Option, вшитый в синтаксис языка: тот же короткий замыкающий эффект, но без пользовательского типа. Разница в том, что синтаксическая версия работает только с доступом к полям, а Option — с любыми функциями, включая ваши доменные.
Где Option — правильный выбор: отсутствие значения не требует объяснения. «В кэше нет ключа» — и так понятно. Где неправильный: отсутствие требует объяснения. parseAge("abc") вернёт None, и вызывающий не узнает, была строка пустой, нечисловой или отрицательной. Тут нужен следующий инструмент.
Either и Result: ошибка с причиной
data Either e a = Left e | Right a
Either — сумма двух произвольных типов. По исторической конвенции в цепочках Right («правый», а также «правильный») означает успех, Left — ошибку. Прикладные языки обычно называют то же самое понятнее:
| Язык | Тип | Успех | Ошибка |
|---|---|---|---|
| Haskell | Either e a |
Right a |
Left e |
| Rust | Result<T, E> |
Ok(T) |
Err(E) |
| Elixir/Erlang | кортеж | {:ok, value} |
{:error, reason} |
| Scala | Either[E, A] |
Right(a) |
Left(e) |
| F# | Result<'T,'TError> |
Ok x |
Error e |
| Kotlin (Arrow) | Either<E, A> |
Right(a) |
Left(e) |
Пишем свой Result на TypeScript — он нам понадобится дальше:
type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
// map трогает только успех
const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
r.ok ? ok(f(r.value)) : r;
// mapErr трогает только ошибку — нужно для перевода ошибок между слоями
const mapErr = <T, E, F>(r: Result<T, E>, f: (e: E) => F): Result<T, F> =>
r.ok ? r : err(f(r.error));
// andThen (он же flatMap, bind, >>=) — склейка шагов, каждый из которых может упасть
const andThen = <T, U, E>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> =>
r.ok ? f(r.value) : r;
// сворачивание в обычное значение — на самой границе
const fold = <T, E, R>(r: Result<T, E>, onOk: (t: T) => R, onErr: (e: E) => R): R =>
r.ok ? onOk(r.value) : onErr(r.error);
Обратите внимание на map против andThen — это ровно различие функтора и монады из https://courses.digitable.life/post/functional-programming/07-functors-and-applicatives/ и https://courses.digitable.life/post/functional-programming/08-monads/. Если ваша функция возвращает голое значение — map. Если она сама возвращает Result — andThen, иначе получите Result<Result<T, E>, E>, и вложенность будет расти с каждым шагом.
Railway-oriented programming: две рельсы вместо десяти if-ов
Скотт Влащин предложил метафору, которая объясняет Result за десять секунд: railway-oriented programming. Программа — двухпутная железная дорога. Верхний путь — успех, нижний — ошибка. Каждая функция это стрелка: получив успех, она может перевести состав на нижний путь. Но обратно стрелок нет — попав на путь ошибок, состав едет по нему до конца, мимо всех оставшихся станций.
Именно это и делает andThen: он проверяет, на каком пути состав, и либо запускает следующий шаг, либо молча пробрасывает ошибку дальше. Весь ветвящийся код проверок схлопывается в одну линейную цепочку.
const registerUser = (raw: string): Result<UserId, AppError> =>
andThen(
andThen(
andThen(parseJson(raw), validateUser),
checkUniqueEmail
),
saveUser
);
Читается всё ещё изнутри наружу, потому что в TypeScript нет оператора конвейера. Дальше увидим, как это выглядит в языках, где синтаксис помогает.
Синтаксис имеет значение: одна цепочка на четырёх языках
Задача: разобрать заказ, проверить его, посчитать сумму и списать деньги. Любой шаг может упасть.
Elixir: with
Elixir не даёт типизированного Result — вместо него конвенция тегированных кортежей, и with разбирает их по образцу. Как только очередной шаг не совпал с образцом, with немедленно возвращает то, что получилось:
def place_order(raw) do
with {:ok, order} <- parse_order(raw),
{:ok, valid} <- validate(order),
{:ok, total} <- calculate_total(valid),
{:ok, receipt} <- charge(valid.customer_id, total) do
{:ok, receipt}
else
{:error, :invalid_json} -> {:error, "тело запроса не разобрано"}
{:error, {:validation, fields}} -> {:error, "ошибки в полях: #{inspect(fields)}"}
{:error, :insufficient_funds} -> {:error, "недостаточно средств"}
other -> {:error, "неизвестная ошибка: #{inspect(other)}"}
end
end
with — самый недооценённый конструкт Elixir (документация). Но у него есть известная ловушка: в блок else попадают несовпадения от всех шагов сразу, и вы теряете информацию о том, какой именно шаг упал. Если два шага могут вернуть {:error, :not_found}, различить их в else невозможно. Лечится тем, что каждый шаг возвращает свой уникальный тег: {:error, {:order, :not_found}} против {:error, {:customer, :not_found}}.
Полнее про идиомы Elixir — в треке https://courses.digitable.life/post/elixir/00-overview/.
Haskell: do-нотация
Каноническая запись, ради которой монады и придумывали. Either e — монада, do — сахар над >>=:
placeOrder :: ByteString -> Either AppError Receipt
placeOrder raw = do
order <- parseOrder raw
valid <- validate order
total <- calculateTotal valid
charge (customerId valid) total
Четыре строки, ноль обработки ошибок в тексте — и при этом ни одна ошибка не потеряна. Каждая стрелка <- — это >>=, который проверяет Left/Right. Тип в сигнатуре честно говорит: «вернёт Receipt или AppError, третьего не дано».
Python: сопоставление с образцом или библиотека
Голый Python:
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T"); E = TypeVar("E")
@dataclass(frozen=True, slots=True)
class Ok(Generic[T]):
value: T
@dataclass(frozen=True, slots=True)
class Err(Generic[E]):
error: E
Result = Ok[T] | Err[E]
def place_order(raw: bytes) -> Result[Receipt, AppError]:
match parse_order(raw):
case Err(_) as e: return e
case Ok(order):
match validate(order):
case Err(_) as e: return e
case Ok(valid):
... # лестница растёт
Видно, что без do-нотации это быстро становится нечитаемым. Практичный ответ — библиотека returns, которая приносит Result, bind и декоратор @safe, превращающий бросающую функцию в возвращающую Result:
from returns.result import Result, Success, Failure, safe
from returns.pipeline import flow
from returns.pointfree import bind
@safe # ловит исключения и заворачивает в Failure
def parse_order(raw: bytes) -> Order:
return Order(**json.loads(raw))
def place_order(raw: bytes) -> Result[Receipt, Exception]:
return flow(
parse_order(raw),
bind(validate),
bind(calculate_total),
bind(charge),
)
@safe — важный приём: он живёт на границе с библиотечным кодом, который кидает, и переводит его в мир значений. Внутри домена исключений уже нет.
TypeScript: библиотека neverthrow
import { ResultAsync, ok, err } from "neverthrow";
const placeOrder = (raw: string): ResultAsync<Receipt, AppError> =>
parseOrder(raw)
.andThen(validate)
.andThen(calculateTotal)
.asyncAndThen((total) => charge(total));
Методы-цепочки в TypeScript читаются даже лучше свободных функций. ResultAsync решает отдельную боль: Promise<Result<T, E>> требует await на каждом шаге и разворачивания вручную, а ResultAsync совмещает оба слоя в один тип — это, по сути, готовый монадный трансформер.
Проектирование типа ошибки
Тип ошибки — это часть публичного API, и относиться к нему надо как к API. Три уровня зрелости:
Уровень 0: строка. Result<T, string> пишется за секунду и годится для прототипа. Но программно среагировать нельзя: if (e.error === "не найдено") — это парсинг сообщений, они меняются при первом же переводе интерфейса.
Уровень 1: сумма-тип. Ошибка — перечисление вариантов с данными:
type OrderError =
| { tag: "invalidJson"; position: number }
| { tag: "validation"; fields: ReadonlyArray<FieldError> }
| { tag: "productNotFound"; sku: string }
| { tag: "insufficientFunds"; required: Money; available: Money }
| { tag: "paymentGatewayDown"; retryAfter: number };
Теперь компилятор проверяет полноту разбора в switch, каждый вариант несёт ровно те данные, которые нужны для реакции, а добавление нового варианта заставит обновить все места обработки. Это то самое «делаем недопустимые состояния непредставимыми» из https://courses.digitable.life/post/functional-programming/06-adt-and-pattern-matching/, применённое к ошибкам.
Уровень 2: разделение по слоям. Ошибки репозитория не должны просачиваться в HTTP-слой. Каждый слой имеет свой тип ошибок, а на границе стоит mapErr:
const handler = (req: Request) =>
fold(
mapErr(orderService.place(req.body), toHttpError), // домен -> HTTP
(receipt) => json(200, receipt),
(httpErr) => json(httpErr.status, { message: httpErr.message })
);
Без этого перевода тип ошибки на верхнем уровне превращается в объединение из сорока вариантов, половина которых — детали драйвера базы. Это самая частая практическая проблема типизированных ошибок и, что важно, единственная, которую надо решать дисциплиной, а не библиотекой.
причина не нужна"| OPT["Option / Maybe"] Q -->|"нужна причина,
вызывающий может среагировать"| R2{"Нужны ли
все ошибки сразу?"} Q -->|"баг в коде или
сломано окружение"| PANIC["Исключение / краш
let it crash, supervisor"] R2 -->|"нет, первой достаточно"| RES["Result / Either
монадическая цепочка"] R2 -->|"да, показать пользователю все"| VAL["Validation
аппликативное накопление"] style OPT stroke:#6f8fb8,stroke-width:2px style RES stroke:#5f9e7a,stroke-width:2px style VAL stroke:#c08a5a,stroke-width:2px style PANIC stroke:#c06a5a,stroke-width:2px
Ошибки против дефектов: не всё нужно заворачивать в Result
Ключевое различие, которое экономит килограммы кода: ожидаемый отказ и баг.
- Ожидаемый отказ — часть предметной области. Пользователь ввёл кривой email, на счёте нет денег, внешний сервис вернул 503. Это не поломка, это нормальный сценарий, который надо промоделировать. Сюда —
Result. - Дефект — нарушенный инвариант. Индекс за границей массива, деление на ноль в формуле,
nullтам, где по конструкции его быть не может, кончилась память. Заворачивать это вResultбессмысленно: вызывающий не может ничего осмысленного сделать, а вы вынудите его писать обработку невозможного случая. Сюда — исключение,panic, краш процесса.
Erlang и Elixir довели вторую половину до философии «let it crash»: процесс, столкнувшийся с непредусмотренным состоянием, умирает, супервизор поднимает свежий из известного хорошего состояния. Это надёжнее, чем пытаться чинить процесс с испорченной памятью, и именно поэтому в Elixir сосуществуют {:error, reason} (домен) и raise (дефект) — они решают разные задачи, а не конкурируют. Rust проводит ту же границу через Result против panic!, Haskell — через Either против error/throwIO.
Практическое правило: если в ответ на ошибку вызывающий код напишет что-то осмысленнее, чем «залогировать и упасть», — это Result. Иначе — исключение.
Накопление ошибок: где монада перестаёт справляться
Форма регистрации: имя, email, возраст, пароль. Пользователь ошибся в трёх полях. Что покажет монадическая цепочка?
const validateForm = (raw: RawForm): Result<User, FieldError> =>
andThen(validateName(raw.name), (name) =>
andThen(validateEmail(raw.email), (email) =>
andThen(validateAge(raw.age), (age) =>
map(validatePassword(raw.password), (pwd) => ({ name, email, age, pwd }))
)
)
);
Покажет одну ошибку — первую. Пользователь исправит её, отправит форму заново, получит вторую. Три раунда вместо одного. Это не баг реализации, это математическое свойство монады: andThen обязан получить значение из предыдущего шага, чтобы вызвать следующий. Нет значения — следующий шаг физически не запускается.
Но посмотрите на четыре наши проверки: они не зависят друг от друга. validateAge не нужен результат validateEmail. А раз зависимости нет, монада — избыточно сильный инструмент. Нужен аппликатив.
Аппликатив (https://courses.digitable.life/post/functional-programming/07-functors-and-applicatives/) умеет применить функцию нескольких аргументов к нескольким независимым контейнерам. Все контейнеры вычисляются, и если ошибок несколько — их можно соединить, а не выбрать первую. Единственное требование к типу ошибки: должна существовать операция «склеить две ошибки в одну». Для списка это конкатенация, для множества — объединение, для строки — склейка. Формально это полугруппа (см. иерархию в https://courses.digitable.life/post/functional-programming/09-functor-map/).
Validation на TypeScript
type Validation<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly errors: ReadonlyArray<E> }; // МНОЖЕСТВЕННОЕ число
const vok = <T>(value: T): Validation<T, never> => ({ ok: true, value });
const verr = <E>(...errors: E[]): Validation<never, E> => ({ ok: false, errors });
// map2 — применить функцию двух аргументов, собрав ошибки с обеих сторон
const map2 = <A, B, C, E>(
va: Validation<A, E>,
vb: Validation<B, E>,
f: (a: A, b: B) => C
): Validation<C, E> => {
if (va.ok && vb.ok) return vok(f(va.value, vb.value));
if (!va.ok && !vb.ok) return { ok: false, errors: [...va.errors, ...vb.errors] };
return va.ok ? vb : va;
};
// собираем объект из четырёх независимых проверок
const validateForm = (raw: RawForm): Validation<User, FieldError> =>
map2(
map2(validateName(raw.name), validateEmail(raw.email), (name, email) => ({ name, email })),
map2(validateAge(raw.age), validatePassword(raw.password), (age, pwd) => ({ age, pwd })),
(a, b) => ({ ...a, ...b })
);
Пользователь получает все четыре ошибки за один заход. Разница в UX колоссальная, разница в коде — одна строка конкатенации массивов.
Почему Validation не монада. Реализовать andThen для него можно, но тогда закон согласованности ap и andThen нарушится: монадический путь замкнётся на первой ошибке, аппликативный — накопит. Один тип не может делать оба по одному интерфейсу. Поэтому в библиотеках это два разных типа: Either и Validation в Haskell (пакет validation), Either и EitherNel/zipOrAccumulate в Arrow, Result и Validation в fp-ts. Между ними есть дешёвая конверсия туда-обратно — типичный поток такой: накопили ошибки валидации аппликативом, перевели в Either, дальше пошли монадической цепочкой.
Тот же приём на Elixir и Haskell
Elixir без типов делает это через reduce, разделяя удачные и неудачные проверки:
def validate_form(raw) do
checks = [
{:name, validate_name(raw["name"])},
{:email, validate_email(raw["email"])},
{:age, validate_age(raw["age"])},
{:password, validate_password(raw["password"])}
]
errors =
for {field, {:error, reason}} <- checks, do: {field, reason}
case errors do
[] ->
fields = for {field, {:ok, value}} <- checks, into: %{}, do: {field, value}
{:ok, struct(User, fields)}
errors ->
{:error, {:validation, errors}} # все ошибки сразу
end
end
Именно так устроен Ecto.Changeset: он прогоняет все validate_* и накапливает ошибки в поле errors, потому что форма должна показать их разом. Это аппликативное накопление, просто без слова «аппликатив» в документации.
Haskell — каноническая запись, где вся разница между режимами сводится к выбору типа:
import Data.Validation -- Validation e a = Failure e | Success a
validateForm :: RawForm -> Validation [FieldError] User
validateForm raw =
User <$> validateName (rawName raw)
<*> validateEmail (rawEmail raw)
<*> validateAge (rawAge raw)
<*> validatePassword (rawPassword raw)
Тот же самый код с Either [FieldError] User вместо Validation вернёт первую ошибку. Ни одного символа менять не надо — меняется только тип. Это и есть смысл разговоров про то, что «аппликатив слабее монады»: слабее по выразительности, зато позволяет то, чего монада не может, — не зависеть от порядка и накапливать.
traverse и sequence: список результатов против результата списка
Одна из самых частых задач: есть список входов, к каждому применяется падающая функция. Нужен Result<List<T>, E>, а не List<Result<T, E>>.
// traverse: применить f ко всем, замкнуться на первой ошибке
const traverse = <A, B, E>(
xs: ReadonlyArray<A>,
f: (a: A) => Result<B, E>
): Result<ReadonlyArray<B>, E> => {
const out: B[] = [];
for (const x of xs) {
const r = f(x);
if (!r.ok) return r; // короткое замыкание
out.push(r.value);
}
return ok(out);
};
// traverseV: применить ко всем, накопить все ошибки
const traverseV = <A, B, E>(
xs: ReadonlyArray<A>,
f: (a: A) => Result<B, E>
): Validation<ReadonlyArray<B>, E> => {
const out: B[] = [];
const errors: E[] = [];
for (const x of xs) {
const r = f(x);
r.ok ? out.push(r.value) : errors.push(r.error);
}
return errors.length === 0 ? vok(out) : { ok: false, errors };
};
sequence — частный случай: traverse с функцией identity, превращающий List<Result<T,E>> в Result<List<T>,E>. Сложность обоих — O(n) по времени; по памяти O(n) для накапливающей версии и O(k) для замыкающей, где k — число обработанных до первой ошибки элементов. В Haskell это traverse/mapM из Traversable, в Rust — collect::<Result<Vec<_>, _>>(), в fp-ts — A.traverse(E.Applicative). Название разное, суть одна.
Разбор строки CSV на 100 000 записей — классический случай, где нужна накапливающая версия: пользователю надо показать все битые строки, а не первую.
Границы: где чистое ядро встречается с грязным миром
Типизированные ошибки работают внутри вашего кода. Снаружи — библиотеки, которые кидают, сеть, которая отваливается, и драйверы БД со своими исключениями. Схема, которая работает: периметр переводит исключения в значения, ядро живёт только на значениях, выход переводит значения обратно в протокол.
Три правила периметра:
- Конвертируй сразу. Не пускай
SQLExceptionдальше репозитория.@safeв Python,Result.fromThrowableв neverthrow,try/rescueвокруг вызова в Elixir,tryCatchв fp-ts. - Не теряй причину. Переводя
DbErrorвOrderError, сохраняй оригинал в полеcauseи логируй его. Иначе получите прод-инцидент с сообщением «внутренняя ошибка» и нулём информации. - Не конвертируй дефекты.
OutOfMemoryErrorиNullPointerExceptionот собственного бага заворачивать вResultне нужно — пусть падают. Ловля «всего подряд» на периметре превращает баги в тихие 500-е.
Подробнее про архитектуру «функциональное ядро, императивная оболочка» — в https://courses.digitable.life/post/functional-programming/16-architecture-and-practice/, про эффекты и IO — в https://courses.digitable.life/post/functional-programming/13-effects-and-io/.
Честная цена
Раздел, которого обычно нет в статьях про монады. Типизированные ошибки — не бесплатный обед.
Производительность
Каждый Ok/Err — это аллокация объекта в куче. В цикле на миллион итераций это заметно:
- JVM/.NET. Escape-анализ часто устраняет аллокацию, если
Resultне покидает метод, но не гарантированно. Rust решает вопрос радикально:Result— обычный enum на стеке, размерResult<T, E>равен максимуму размеров вариантов плюс тег, аллокаций нет, и после оптимизации?компилируется в проверку регистра. - JavaScript/TypeScript. Объекты
{ok: true, value}создают давление на GC. В горячем пути (парсер, обход дерева на миллионы узлов) разница с исключениями или с возвратомnullможет достигать разов. Но: исключения в V8 — тоже недёшево,throwсо сборкой стека стоит порядка микросекунд, что на порядки дороже одной аллокации. Правило:Resultдорожеnullи дешевлеthrow, если ошибки происходят регулярно; если ошибка — событие раз в миллион вызовов, исключение выигрывает. - Elixir/Erlang. Кортеж
{:ok, value}— два слова на куче процесса, копеечно. Эта конвенция потому и стала повсеместной.
Практический вывод: измеряйте, а не гадайте, и не выкидывайте Result из всего кода из-за одного горячего цикла. Оптимизируйте локально — внутри цикла работайте на голых значениях, заворачивайте на выходе.
Стек-трейсы
Самая недооценённая потеря. Исключение приносит с собой полный стек — вы видите, откуда пришёл вызов. Err("не найдено") не приносит ничего: вы знаете, что ошибка была, но не знаете, из какой ветки кода она пришла и с какими аргументами. В цепочке из двадцати andThen это болезненно.
Что делать:
- Включайте контекст в тип ошибки: не
notFound, а{tag: "notFound", entity: "order", id: "42"}. - Накапливайте «цепочку причин» при переводе между слоями — по образцу
anyhow::Contextв Rust илиException.InnerExceptionв .NET. - В момент создания
Errна периметре можно один раз захватить стек (new Error().stackв JS,__traceback__в Python) и положить в поле — платите за это только на пути ошибки.
Кривая обучения и когнитивная нагрузка
Команда, впервые увидевшая andThen, потратит недели на привыкание. И это ещё лёгкий случай — тяжёлый начинается, когда Result встречается с другими эффектами:
Promise<Result<T, E>>— два слоя, каждыйawaitтребует ручной распаковки. Лечится специальным типом (ResultAsync,TaskEitherв fp-ts), но это ещё одна абстракция.Result<Option<T>, E>— «запрос удался, но записи нет». Логично, читается плохо, и на каждой границе идут переводы туда-сюда.- В Haskell/Scala лечение называется монадными трансформерами (
ExceptT,EitherT) — и порождает известную проблему стека трансформеров, где сигнатуры превращаются в частокол, а производительность падает из-за слоёв обёрток.
Честный ориентир: типизированные ошибки окупаются в доменной логике с богатым набором отказов и не окупаются в скриптах, одноразовых миграциях и тонких адаптерах, где всё равно всё сводится к «упало — логируем и выходим».
Где ФП-подход прямо мешает
- Глубокий выход из вложенности. Исключение умеет пролететь двадцать кадров стека.
Resultобязан быть протащен вручную через каждый уровень — если промежуточные функции чужие, вы просто не сможете этого сделать. - Экосистема, которая кидает. Вокруг любого вызова библиотеки нужна обёртка. В Python и JS это заметный объём шаблонного кода.
- Динамические языки. Без компилятора, проверяющего полноту разбора, половина гарантий испаряется: ничто не мешает забыть ветку
{:error, _}. В Elixir это частично компенсируют dialyzer и падение при несовпадении образца. - Ошибка ради ошибки. Заворачивать в
Resultто, что никогда не падает, — чистый оверхед.Result<number, never>в коде означает, что кто-то увлёкся.
Типичные ошибки
Result, который никто не проверяет. В языках без#[must_use](TypeScript, Python, Elixir) вернутьResultи молча его проигнорировать — так же просто, как проигнорировать код возврата в C. Помогают линтеры и ревью-дисциплина; в Rust компилятор предупреждает сам.unwrap()в проде.getOrThrow,Option.get,result.unwrap()— это возврат к исключениям, только с худшим сообщением. Допустимо в тестах и там, где инвариант доказан рядом комментарием.- Строка как тип ошибки в публичном API. Обсудили выше: сообщения меняются, программная реакция невозможна.
- Проглатывание через
getOrElse(default). Подстановка значения по умолчанию вместо обработки ошибки маскирует проблему: система «работает», выдавая нули. - Один общий
AppErrorна весь монолит. Объединение из 60 вариантов, из которых в каждом конкретном месте возможны три. Разбор становится ритуалом с веткойdefault: throw. - Монада там, где нужен аппликатив. Форма показывает по одной ошибке за раз — самый частый и самый заметный пользователю симптом.
- Аппликатив там, где нужна монада. Пытаться «накопить» ошибки шагов, которые зависят друг от друга: если парсинг упал, валидировать нечего, и запуск следующей проверки на пустом месте даст мусорную вторую ошибку.
OptionвместоResultв домене.Noneбез причины на границе API вынуждает вызывающего гадать.
Мини-итог
- Ошибка — значение, а не событие. Это единственная идея, всё остальное — её следствия.
Option/Maybe— отсутствие значения, причина не нужна.Either/Result— отказ с причиной, вызывающий может среагировать. Исключение/краш — дефект, реагировать нечем.- Монадическая цепочка (
andThen,with, do-нотация) даёт линейный код и замыкается на первой ошибке — то, что нужно для зависимых шагов. - Аппликативное накопление (
Validation,map2,<*>) собирает все ошибки независимых проверок сразу — то, что нужно формам и импортам данных. Требует, чтобы ошибки умели склеиваться. traverse/sequenceпереворачивают «список результатов» в «результат списка», O(n).- Тип ошибки — сумма-тип, свой на каждом слое, с переводом через
mapErrна границах. - Цена реальна: аллокации, потеря стек-трейсов, вложенность эффектов, время обучения команды. Применяйте там, где отказов много и они доменные; не применяйте в скриптах и горячих циклах.
Что почитать
- Scott Wlaschin. Railway Oriented Programming — и книга Domain Modeling Made Functional (Pragmatic Bookshelf, 2018).
- Joe Duffy. The Error Model — разбор моделей ошибок из опыта Midori.
- The Rust Book, глава 9: Error Handling — лучшее практическое введение в
Resultпротивpanic!. - Arrow: Typed Errors — зрелый API накопления ошибок для JVM.
- returns для Python, neverthrow и fp-ts для TypeScript.
- Erlang: принципы супервизии — почему «пусть падает» это тоже стратегия обработки ошибок.
Что дальше
Мы научились описывать вычисления, которые могут не дать результата. Следующий шаг — вычисления, которые дают результат не сразу: ленивость позволяет описать бесконечный поток и взять из него ровно столько, сколько нужно, а заодно объясняет, почему && и || работают именно так.