Сигнатура вашей функции говорит, что она возвращает User. Это не так. Она возвращает User или взрывается. Система типов просто не знает о второй ветке.

Это фундаментальное нечестство обработки ошибок через исключения. Каждый throw — это путь управления потоком, который ваш компилятор не видит, не проверяет и не может обеспечить. В итоге у вас код, который идеально проходит типизацию, и всё равно падает в продакшене, потому что кто-то забыл try/catch на три слоя вызовов.

Явный возврат ошибок исправляет это. Он делает отказ полноправным гражданином вашей системы типов. Вопрос в том, стоит ли эргономическая цена такой честности.

Что означает корректность на уровне типов для ошибок

Корректность на уровне типов означает, что ваши типы говорят правду о том, что функция может произвести. Если функция может упасть, тип возврата должен это говорить. Если не может — тип возврата должен гарантировать успех.

Rust кодирует это через Result<T, E>:

fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse()
}

Сигнатура полная. Вызывающие знают, что есть два исхода. Компилятор заставляет их обрабатывать оба.

TypeScript по умолчанию — нет. Функция, которая выбрасывает, выглядит идентично той, которая не выбрасывает:

function parsePort(s: string): number {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    throw new Error(`Invalid port: ${s}`);
  }
  return n;
}

Тип возврата говорит number. Он должен говорить number | Error, или точнее, Result<number, Error>. Но он этого не говорит, поэтому вызывающие не получают сигнала, что им нужно защищаться от отказа.

Как выброшенные ошибки прячутся в графе вызовов

Ущерб накапливается по мере углубления стека вызовов. Выброшенная ошибка в листовой функции заставляет каждый промежуточный слой либо поймать её, либо неявно распространить. Ни один из этих путей распространения не появляется ни в одной сигнатуре типа.

Представьте загрузчик конфигурации, который вызывает парсер, который вызывает валидатор. Если валидатор выбрасывает, парсер и загрузчик тоже становятся выбрасывающими. Но их типы не меняются. Вы не можете посмотреть на loadConfig() и узнать, что она может упасть. Вам придётся читать каждую строчку каждой зависимости или ждать падения в рантайме.

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

Явный возврат ошибок переворачивает это. Путь отказа виден на каждом слое:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function parsePort(s: string): Result<number, string> {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    return { ok: false, error: `Invalid port: ${s}` };
  }
  return { ok: true, value: n };
}

function loadConfig(raw: Record<string, string>): Result<Config, string> {
  const portResult = parsePort(raw.port);
  if (!portResult.ok) return portResult;

  const host = raw.host || "localhost";

  return { ok: true, value: { port: portResult.value, host } };
}

Теперь сигнатура loadConfig рассказывает всю историю. Вы знаете, что она может упасть, ещё до того, как прочитали хоть одну строчку тела.

Настоящий компромисс — в эргономике, а не в выразительности

Честный подход многословнее. Никому не нравится писать { ok: false, error: ... } вместо throw new Error(...). Визуальный шум накапливается, особенно когда вы выстраиваете в цепочку несколько операций, которые могут упасть.

Поэтому большинство языков с checked-исключениями от них отказались. Checked-исключения Java пытались навязать явную обработку на уровне типов, но синтаксис был карательным. Оператор ? в Rust и комбинаторы Result, такие как map и and_then, возвращают большую часть этой эргономики. У TypeScript нет оператора ?, но вы можете приблизиться к нему с помощью вспомогательных функций:

function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  return result.ok ? { ok: true, value: fn(result.value) } : result;
}

function flatMap<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

Это всё ещё более громоздко, чем в Rust. Если вы пишете на TypeScript, библиотеки вроде neverthrow предоставляют тип Result с методами, которые можно выстраивать в цепочку и которые чувствуются почти нативно. Накладные расходы реальны, но они меньше, чем кажется, как только у вас есть вспомогательные средства.

Есть ещё одна цена. Stack traces из выброшенных ошибок богатые и автоматические. Возвращённые ошибки — просто значения. Если вам нужен stack trace, вы должны собрать его сами. Для отладки это имеет значение. Для предсказуемой обработки ошибок в бизнес-логике — обычно нет.

Когда выбрасывание всё ещё имеет смысл

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

Правило большого пальца: если отказ — часть вашего домена, возвращайте его. Если это баг в программе или катастрофический системный сбой, выбрасывайте. JSON.parse, который выбрасывает на некорректном входе, раздражает, потому что невалидный JSON — это обычное дело. Array.prototype.map, который выбрасывает на null-массиве, — это нормально, потому что вызывать .map на null — это баг.

На практике это означает, что ваши I/O-границы, парсеры, валидаторы и бизнес-правила должны возвращать ошибки. Ваши инварианты, утверждения и по-настоящему катастрофические условия могут всё ещё выбрасываться.

Начните с границ вашего API

Вам не нужно рефакторить весь кодбейс. Места с наибольшей отдачей для внедрения явного возврата ошибок — ваши публичные API и I/O-слои. Это границы, где вызывающим нужно знать, что может пойти не так.

Выберите один модуль. Поменяйте его типы возврата на Result<T, E>. Обновите его вызывающих. Посмотрите, как это ощущается. Паттерн затягивает своего рода: как только вы перестанете рыскать по stack traces в поисках пропущенных try/catch-блоков, вы осознаете, сколько ментальных затрат вам стоили выброшенные исключения.

Корректность на уровне типов для ошибок — это не о написании идеального кода. Это о написании кода, который меньше лжёт.