Функциональное программирование Обработка ошибок в ФП: Option, Either, Result, накопление ошибок
0%

Обработка ошибок в ФП: Option, Either, Result, накопление ошибок

Обработка ошибок в ФП: 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.

Отсюда три системные беды:

  1. Забыть обработать — бесплатно. Компилятор ничего не скажет. Программа упадёт в проде, а не на этапе сборки.
  2. Обработка происходит не там, где надо. Исключение всплывает до первого попавшегося try, а этот try часто написан человеком, который понятия не имел про JSONDecodeError — и написал except Exception: log.warning(...), проглотив реальный баг.
  3. Композиция ломается. Функцию, которая кидает, нельзя честно передать в 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. Если она сама возвращает ResultandThen, иначе получите 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 })
  );

Без этого перевода тип ошибки на верхнем уровне превращается в объединение из сорока вариантов, половина которых — детали драйвера базы. Это самая частая практическая проблема типизированных ошибок и, что важно, единственная, которую надо решать дисциплиной, а не библиотекой.

Ошибки против дефектов: не всё нужно заворачивать в 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 записей — классический случай, где нужна накапливающая версия: пользователю надо показать все битые строки, а не первую.

Границы: где чистое ядро встречается с грязным миром

Типизированные ошибки работают внутри вашего кода. Снаружи — библиотеки, которые кидают, сеть, которая отваливается, и драйверы БД со своими исключениями. Схема, которая работает: периметр переводит исключения в значения, ядро живёт только на значениях, выход переводит значения обратно в протокол.

Три правила периметра:

  1. Конвертируй сразу. Не пускай SQLException дальше репозитория. @safe в Python, Result.fromThrowable в neverthrow, try/rescue вокруг вызова в Elixir, tryCatch в fp-ts.
  2. Не теряй причину. Переводя DbError в OrderError, сохраняй оригинал в поле cause и логируй его. Иначе получите прод-инцидент с сообщением «внутренняя ошибка» и нулём информации.
  3. Не конвертируй дефекты. 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> в коде означает, что кто-то увлёкся.

Типичные ошибки

  1. Result, который никто не проверяет. В языках без #[must_use] (TypeScript, Python, Elixir) вернуть Result и молча его проигнорировать — так же просто, как проигнорировать код возврата в C. Помогают линтеры и ревью-дисциплина; в Rust компилятор предупреждает сам.
  2. unwrap() в проде. getOrThrow, Option.get, result.unwrap() — это возврат к исключениям, только с худшим сообщением. Допустимо в тестах и там, где инвариант доказан рядом комментарием.
  3. Строка как тип ошибки в публичном API. Обсудили выше: сообщения меняются, программная реакция невозможна.
  4. Проглатывание через getOrElse(default). Подстановка значения по умолчанию вместо обработки ошибки маскирует проблему: система «работает», выдавая нули.
  5. Один общий AppError на весь монолит. Объединение из 60 вариантов, из которых в каждом конкретном месте возможны три. Разбор становится ритуалом с веткой default: throw.
  6. Монада там, где нужен аппликатив. Форма показывает по одной ошибке за раз — самый частый и самый заметный пользователю симптом.
  7. Аппликатив там, где нужна монада. Пытаться «накопить» ошибки шагов, которые зависят друг от друга: если парсинг упал, валидировать нечего, и запуск следующей проверки на пустом месте даст мусорную вторую ошибку.
  8. Option вместо Result в домене. None без причины на границе API вынуждает вызывающего гадать.

Мини-итог

  • Ошибка — значение, а не событие. Это единственная идея, всё остальное — её следствия.
  • Option/Maybe — отсутствие значения, причина не нужна. Either/Result — отказ с причиной, вызывающий может среагировать. Исключение/краш — дефект, реагировать нечем.
  • Монадическая цепочка (andThen, with, do-нотация) даёт линейный код и замыкается на первой ошибке — то, что нужно для зависимых шагов.
  • Аппликативное накопление (Validation, map2, <*>) собирает все ошибки независимых проверок сразу — то, что нужно формам и импортам данных. Требует, чтобы ошибки умели склеиваться.
  • traverse/sequence переворачивают «список результатов» в «результат списка», O(n).
  • Тип ошибки — сумма-тип, свой на каждом слое, с переводом через mapErr на границах.
  • Цена реальна: аллокации, потеря стек-трейсов, вложенность эффектов, время обучения команды. Применяйте там, где отказов много и они доменные; не применяйте в скриптах и горячих циклах.

Что почитать

Что дальше

Мы научились описывать вычисления, которые могут не дать результата. Следующий шаг — вычисления, которые дают результат не сразу: ленивость позволяет описать бесконечный поток и взять из него ровно столько, сколько нужно, а заодно объясняет, почему && и || работают именно так.

Ленивость, бесконечные структуры и потоки данных

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов