← Глава 26. Схемы как источник истины Содержание Глава 28. Декларации и чужой код →

Глава 27. Ошибки и исключения

Глава 27. Ошибки и исключения

Поймать по типу нельзя

Заведём ошибку оплаты и попробуем поймать именно её — ровно так, как пишут на любом статически типизированном языке:

class PaymentError extends Error {}

try {
  pay(order);
} catch (e: PaymentError) {} // ошибка: аннотация переменной catch
//          ^^^^^^^^^^^^
// Catch clause variable type annotation must be 'any' or 'unknown' if specified.

Компилятор отказывается. Не «не нашёл такой тип», не «опечатка» — он сообщает, что аннотация у переменной catch вообще запрещена, и единственное, что ей позволено, — это any или unknown. Уберём аннотацию и посмотрим, что за тип достанется нам по умолчанию:

try {
  pay(order);
} catch (e) {
  e; // тип: unknown
}

unknown — верхний тип, в который укладывается что угодно и из которого без сужения нельзя сделать почти ничего (Глава 11). То есть у пойманного значения нет даже намёка на то, что это PaymentError, Error или хотя бы объект. Это первый удар. Второй сильнее. Посмотрим на сигнатуру самой pay:

function pay(order: Order): void {
  if (order.total <= 0) throw new PaymentError("сумма должна быть положительной");
  // ...
}

Тип возврата — void. Про PaymentError, который функция бросает в первой же строке, в сигнатуре нет ни слова. Вызывающий, глядя на pay(order): void, не получает ни малейшего сигнала, что отсюда может вылететь исключение. Компилятор не напомнит обернуть вызов в try, не заставит обработать ошибку, не подсветит забытую ветку. С точки зрения системы типов pay просто возвращает void и всё.

Сложите два наблюдения. Поймать по типу нельзя — пойманное всегда unknown. И бросаемое не объявлено — сигнатура о нём молчит. Рефлекс, привезённый из мира, где catch (PaymentError e) — рутина, а компилятор пересчитывает за вами все непойманные исключения, здесь не срабатывает дважды. Вопрос главы: почему по типу ловить нельзя, почему throw невидим типам — и как тогда обрабатывать ошибки, если система типов их не отслеживает.

Почему catch не типизируется

Ответ на первую половину вопроса лежит там же, где лежат ответы на большинство «почему» этого языка, — в стирании (Глава 3). Чтобы catch (e: PaymentError) работал, рантайму пришлось бы при влёте исключения в блок сверить его тип с PaymentError и решить, заходить в этот catch или лететь дальше. Это диспетчеризация по типу в рантайме. А типов в рантайме нет: к моменту запуска PaymentError как тип стёрт, бирки на значении не висит, сверять не с чем.

Разберём, что реально умеет JavaScript. throw бросает любое значение, а не только экземпляр Error:

throw new Error("сломалось");
throw "сломалось";   // легальный JavaScript: бросаем строку
throw 42;             // и число
throw { code: 500 };  // и обычный объект
throw undefined;      // и это

А catch — один на блок, без вариантов, и ловит всё брошенное, чем бы оно ни было:

try {
  doSomething();
} catch (e) {
  // сюда попадёт что угодно из перечисленного выше
}

В JavaScript нет конструкции «несколько catch, по одному на каждый тип исключения». Есть один блок, в который влетает один аргумент неизвестной природы. Раз так, единственный честный тип для e — тот, что обещает про значение ровно ничего. До TypeScript 4.4 им был any (Глава 11), и это была дыра: на e можно было звать любой метод, и компилятор молчал. С флагом useUnknownInCatchVariables, который входит в strict, типом стал unknown. Разница принципиальна: any развязывает руки и врёт, unknown связывает руки и не врёт. С unknown компилятор требует доказать, что за значение в руках, прежде чем что-то с ним делать:

try {
  pay(order);
} catch (e) {
  console.log(e.message); // ошибка: 'e' имеет тип 'unknown'
}

Чтобы добраться до .message, нужно сузить (narrowing) — доказать рантайм-проверкой, что значение и впрямь то, чем мы его считаем (Глава 25). Поскольку диспетчеризации по типу нам не дали, мы пишем её руками:

class PaymentError extends Error {
  constructor(message: string, readonly code: string) {
    super(message);
  }
}

try {
  pay(order);
} catch (e) {
  if (e instanceof PaymentError) {
    // здесь e сужен до PaymentError
    console.error(`Оплата отклонена (${e.code}): ${e.message}`);
  } else if (e instanceof Error) {
    // любой другой Error
    console.error(e.message);
  } else {
    // вообще не Error — строка, число, что угодно
    throw e; // не наше, не понимаем — пробрасываем дальше
  }
}

Это и есть рабочий паттерн. instanceof — настоящий оператор JavaScript, он проверяет цепочку прототипов реального объекта и переживает стирание, потому что стирать ему нечего (Глава 1). Тот catch (e: PaymentError), которого мы хотели, — это вот этот if (e instanceof PaymentError), выписанный вручную. Язык не сделал диспетчеризацию за нас; он отдал нам unknown и инструменты сужения и сказал: разбирай сам, по одной ветке за раз. Что не опознали — пробрасываем, потому что глотать чужую ошибку хуже, чем уронить программу.

throw невидим для системы типов

Сужение в catch закрывает первую половину проблемы. Вторая — глубже и неприятнее. Вернёмся к сигнатуре:

function pay(order: Order): void {
  if (order.total <= 0) throw new PaymentError("сумма должна быть положительной", "AMOUNT");
  // ...
}

: void — это, строго говоря, неправда. Функция не всегда «просто возвращает ничего»: иногда она вместо возврата выбрасывает PaymentError. Но тип возврата описывает только нормальный выход и молчит про аварийный. В сигнатуре нет места, куда вписать «а ещё я могу бросить PaymentError». Синтаксиса вроде throws PaymentError в TypeScript не существует.

Последствие видно на вызывающей стороне:

function checkout(order: Order): void {
  pay(order);            // компилятор: всё в порядке, pay() возвращает void
  markAsPaid(order);     // спокойно идём дальше
}

С точки зрения проверяющей программы checkout безупречен. Никакого сигнала, что pay способна оборвать поток управления исключением и markAsPaid может не выполниться. Забыли try/catch — компилятор не возразит. Обработали не ту ошибку — не возразит. Не обработали никакую — тоже. throw создаёт внеполосный поток управления (out-of-band control flow): второй, скрытый выход из функции, который система типов не отслеживает вовсе.

Это не недосмотр и не лень авторов. TypeScript описывает типы поверх существующего JavaScript, а в JavaScript throw именно таков — может вылететь откуда угодно и о себе не объявляет. Натянуть на это «проверяемые исключения» означало бы потребовать, чтобы каждая функция перечисляла всё, что способна бросить она и весь её транзитивный вызов, включая чужие нетипизированные библиотеки. Для языка, чья задача — типизировать живой динамический мир, это нереализуемо. Поэтому выбран честный минимум: throw не отслеживается, тип возврата описывает только успешный путь.

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

Result: ошибка как значение в типе

Идея простая до банальности: раз тип возврата отслеживается, а throw — нет, перенесём ошибку в тип возврата. Пусть функция не бросает, а возвращает — либо успех, либо ошибку, одним значением, которое описано размеченным объединением (discriminated union — объединение, ветви которого различаются по общему полю-дискриминанту, Глава 9):

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

Поле ok — дискриминант. Перепишем pay, чтобы ошибка стала частью возврата:

type PayError =
  | { kind: "amount"; message: string }
  | { kind: "declined"; code: string }
  | { kind: "network" };

function pay(order: Order): Result<Order, PayError> {
  if (order.total <= 0) {
    return { ok: false, error: { kind: "amount", message: "сумма должна быть положительной" } };
  }
  // ... при успехе:
  return { ok: true, value: order };
}

Теперь сигнатура pay не лжёт: она прямо говорит, что вернёт либо Order, либо PayError. И вызывающая сторона больше не может пройти мимо ошибки — компилятор не пустит к value, пока не разобрана ветка ok: false:

const result = pay(order);

console.log(result.value); // ошибка: свойства 'value' нет на ветке { ok: false }

if (result.ok) {
  // здесь result сужен до { ok: true; value: Order }
  markAsPaid(result.value);
} else {
  // здесь result сужен до { ok: false; error: PayError }
  console.error("Оплата не прошла:", result.error.kind);
}

Проверка if (result.ok) сужает объединение по дискриминанту: внутри if доступно value, внутри else — error, и перепутать их компилятор не даст. Ошибка из невидимой стала отслеживаемым значением: она в типе, она в выводе, забыть про неё нельзя, потому что без разбора ok к успешному значению не подобраться.

Этот узор уже встречался в книге. safeParse у схемы валидации (Глава 26) возвращает ровно такой Result — { success: true; data: T } | { success: false; error: ... } — вместо того чтобы бросать при невалидных данных. Это не совпадение: оба решают одну задачу — сделать неудачу видимой в типе на границе, где она ожидаема.

За явность платят церемонией. throw бесплатен в точке вызова: бросил — и пиши дальше счастливый путь, а ошибка сама всплывёт наверх по стеку. Result требует разобрать объединение на каждом уровне и протащить ошибку через возвраты руками. Это видимая цена видимости. В обмен компилятор гарантирует, что ни одна ветка ok: false не осталась необработанной, — то самое принуждение, которого throw не даёт.

Писать Result руками никто не обязан. Вокруг этой идеи выросли библиотеки: neverthrow даёт готовый Result с цепочечными map/andThen; Either из fp-ts — тот же размеченный выбор в терминах функционального программирования (Left — ошибка, Right — успех); Effect встраивает канал ошибки прямо в тип своего эффекта, так что Effect<A, E> несёт и успешное значение A, и возможную ошибку E. Разные обёртки над одним ходом: ошибка — это значение, и её место — в возвращаемом типе.

Типизированные ошибки внутри throw

Result — не приговор для throw. Иногда бросать удобнее: ошибка летит через много уровней, промежуточным звеньям до неё дела нет, и таскать Result через каждое — лишний шум. Тогда throw можно сделать почти настолько же типизированным, перенеся ту же дисциплину в catch.

Первый приём — моделировать виды ошибок размеченным объединением и сужать в catch по дискриминанту:

type PayError =
  | { kind: "amount"; message: string }
  | { kind: "declined"; code: string }
  | { kind: "network" };

try {
  pay(order); // пусть бросает значения формы PayError
} catch (e) {
  const err = e as PayError; // граница доверия: утверждаем форму осознанно
  switch (err.kind) {
    case "amount":   return showMessage(err.message);
    case "declined": return retryWithCard(err.code);
    case "network":  return scheduleRetry();
    default:
      const _exhaustive: never = err; // забыли ветку — здесь будет ошибка
      throw e;
  }
}

switch по kind исчерпывающе разбирает все виды ошибки; присваивание остатка в never ловит забытую ветку на этапе компиляции (Глава 9). Добавите четвёртый вид в PayError и не обработаете — _exhaustive перестанет компилироваться. Так даже через невидимый throw мы получаем проверку исчерпанности — ценой as PayError, утверждения о форме, за честность которого отвечаем сами (Глава 24).

Второй приём, привычнее и без as, — подклассы Error плюс instanceof:

class AmountError extends Error {
  readonly kind = "amount" as const;
}
class DeclinedError extends Error {
  readonly kind = "declined" as const;
  constructor(readonly code: string) { super(`оплата отклонена: ${code}`); }
}

try {
  pay(order);
} catch (e) {
  if (e instanceof DeclinedError) retryWithCard(e.code);
  else if (e instanceof AmountError) showMessage(e.message);
  else throw e; // не наша ошибка — пробрасываем
}

instanceof опирается на реальную цепочку прототипов и потому переживает стирание (Глава 1). Но у него есть две оговорки, обе родом из устройства рантайма (Глава 3). Первая — межреалмовость: объект, созданный в другом реалме (другом iframe, другом worker, другом контексте Node), не пройдёт instanceof против вашего класса, потому что прототипы — разные, хоть конструктор и одноимённый. Вторая — транспиляция в ES5: при компиляции в старый стандарт наследование от встроенного Error ломается, и instanceof начинает врать; об этом — в «острых краях». Запомним правило, общее для обоих приёмов: что не смоделировали — пробрасываем через throw e. Проглотить незнакомую ошибку хуже, чем дать ей лететь дальше к тому, кто её поймёт.

Глазами джависта

Тут джавист узнаёт ровно то, чего ему весь язык не давал, — и понимает, по чему скучал. В Java исключение может быть объявлено в сигнатуре: void pay(Order o) throws PaymentException. И если PaymentException — проверяемое (checked) исключение, компилятор принуждает вызывающего либо поймать его, либо объявить дальше. Забыть нельзя — программа не скомпилируется. А catch (PaymentException e) — настоящая диспетчеризация по типу: JVM хранит тип объекта в рантайме и умеет выбрать нужный catch из нескольких. Обе вещи держатся на том, что в Java типы реальны во время исполнения (Глава 3). В TypeScript типы стёрты — и обе невозможны: throws в сигнатуре негде объявить, catch по типу не диспетчеризуется. Тот самый вопрос, с которого началась глава, — «почему catch (e: T) не компилируется» — в Java вообще не возникает: там это рутина.

Соблазнительно объявить это чистой потерей. Не спешите — проверяемые исключения и в мире Java спорны до сих пор. Они расползаются вверх по стеку, заставляя переписывать сигнатуры всей цепочки ради одного нового исключения внизу. Они скверно дружат с лямбдами и стримами: функциональные интерфейсы не объявляют throws, и checked-исключение приходится оборачивать. Именно из-за этого Kotlin отказался от проверяемых исключений вовсе, а C# их никогда и не вводил. Так что TypeScript не одинок в своём выборе — он в большой компании языков, решивших, что цена принуждения выше выгоды.

И вот схождение, которое стоит увидеть. Result и канал ошибки Effect — это проверяемые исключения, воскрешённые как значения. Они возвращают то же главное свойство: ошибка видна в сигнатуре, и компилятор принуждает её обработать. Но переносят её не в особое объявление throws, а в обычный возвращаемый тип — размеченным объединением. Java вшила проверяемость в язык намертво и для всех; TypeScript оставил throw непроверяемым, а проверяемость сделал значением, которое подключаешь там, где оно того стоит. То, что джавист считал встроенной гарантией, здесь стало осознанным выбором на каждой границе.

Где ошибки кусаются

Честный разбор начинается с того, что unknown в catch неудобен — и неудобство толкает к побегу. Раздражённый необходимостью сужать, программист пишет catch (e: any) или (e as Error).message — и проверка, которую unknown навязывал, исчезает:

try {
  pay(order);
} catch (e) {
  console.log((e as Error).message); // если бросили строку — message === undefined
}

as Error ничего не проверил, он переписал убеждение компилятора (Глава 24). Бросят строку — и (e as Error).message молча даст undefined. unknown существует именно затем, чтобы заставить проверить; as эту защиту снимает.

Дальше — e instanceof Error бывает ложным чаще, чем кажется. Бросить могут не Error вовсе: чужой код порой делает throw "строка" или throw {code: 500}, и тогда ни одна instanceof Error-ветка не сработает. Межреалмовый объект из другого контекста не пройдёт проверку против вашего класса. А транспилированный в ES5 подкласс Error ломается на ровном месте:

class PaymentError extends Error {} // при target: ES5

const e = new PaymentError("boom");
e instanceof PaymentError; // false! прототип потерян при транспиляции

При компиляции в ES5 наследование от встроенного Error не восстанавливает прототип, и instanceof врёт. Лечится явной починкой цепочки в конструкторе:

class PaymentError extends Error {
  constructor(message: string) {
    super(message);
    Object.setPrototypeOf(this, PaymentError.prototype); // костыль для ES5
  }
}

Вывод практический: всегда держите ветку «вообще не Error». Любой catch, у которого нет финального else, обрабатывающего непонятное значение, рано или поздно встретит то, чего не ждал.

Глотание — отдельный грех. catch (e) {} или catch (e) { return null; } прячет баг: ошибка, которую вы не смоделировали, исчезает бесследно, и программа продолжает работать в неверном состоянии. Правило простое: обработал то, что узнал, — остальное пробрось. Пустой catch почти всегда означает потерянную ошибку.

Асинхронность добавляет своих ловушек. Отклонённый промис без await или .catch становится unhandledRejection, и try/catch вокруг него не сработает — ловить нечего, если не дождались:

try {
  pay(order); // вернула промис, который отклонится; забыли await
} catch (e) {
  // сюда не попадём: к моменту отклонения try уже закрыт
}

Помогает await внутри try либо Result, который вообще не зависит от того, успели ли вы поймать момент отклонения, — ошибка едет в значении, а значение никуда не денется. Ошибки в колбэках (старый стиль (err, data) => ...) try/catch тоже не ловит: исключение из колбэка летит в чужой стек.

У Result своя цена. В глубоких цепочках вызовов церемония накапливается: каждый уровень разбирает объединение и пробрасывает ошибку, и без комбинаторов (map, andThen) код тонет в проверках if (!r.ok) return r. Смешивать throw и Result в одной кодовой базе неряшливо — вызывающий не знает, что его ждёт, проверка возврата или вылетающее исключение. Библиотеки вроде Effect и fp-ts снимают церемонию, но взамен дают крутую кривую обучения и привязку: код, написанный в эффектах, переписать обратно на голые промисы недёшево.

И последнее, тихое. Result<T, string> сводит ошибку к строке — и теряет весь выигрыш. По строке нельзя сделать исчерпывающий switch, нельзя сузить, нельзя заставить компилятор проверить, что разобраны все случаи. string как тип ошибки — это Result, отказавшийся от своей единственной суперсилы. Если уж переносить ошибку в тип, то размеченным объединением видов, а не плоской строкой.

Ошибки — это значения; реши, где они живут

Сдвиг, который должен произойти, не про синтаксис try/catch, а про вопрос «где живёт ошибка». У неё два дома, и выбор между ними — проектное решение, а не дело вкуса.

Первый дом — возвращаемый тип, через Result. Сюда селят ошибки ожидаемые, доменные, восстановимые — те, что вызывающий обязан обработать, потому что они часть нормальной логики: отклонённая оплата, невалидный ввод, не найденный заказ. Это не исключительные ситуации, это штатные исходы, у которых просто не «успешное» значение. Размеченное объединение делает их видимыми, компилятор принуждает разобрать обе ветки — то самое safeParse на границе данных (Глава 26).

Второй дом — throw. Сюда уходят ошибки исключительные, невосстановимые, означающие сломанный инвариант или баг: кончилась память, недостижимая ветка, нарушенное «этого не может быть». Их незачем тащить через каждый возврат — пусть летят вверх к обработчику верхнего уровня, который залогирует и уронит запрос. Но помните: пойманное всегда unknown. Сужайте честно, держите ветку «не Error», пробрасывайте несмоделированное. А если бросаете свои ошибки — моделируйте их виды размеченным объединением, чтобы catch мог разобрать их исчерпывающе.

Граница между домами не всегда очевидна, и здесь помогает один критерий: обязан ли вызывающий принять решение по этой ошибке прямо сейчас? Если да — это Result, ошибка должна быть в сигнатуре и в его лице. Если ошибка означает «дальше работать бессмысленно, спасайся кто может» — это throw, и её место наверху.

И последнее, замыкающее главу. Узнайте в Result и Effect проверяемые исключения, поданные как значения: ту самую видимость ошибки в сигнатуре, которую Java вшила в язык для всех, — но доступную по желанию, на тех границах, где вы решили, что она окупается. А на буквальный вопрос, с которого глава началась, — можно ли ловить по типу — ответ остаётся «нет, из-за стирания». Но теперь у него есть продолжение: либо дискриминируй вручную в catch, либо вообще не доводи до throw и перенеси ошибку в тип. Второе сильнее, потому что зовёт на помощь компилятор.

Итог

Ловить по типу нельзя из-за стирания, а throw невидим системе типов: сигнатура молчит о том, что функция бросает. Кто переносит ожидаемые ошибки из невидимого throw в возвращаемый тип через Result, тот возвращает компилятору контроль над их обработкой — тот самый контроль, что проверяемые исключения дают Java по умолчанию, но здесь подключаемый осознанно и только там, где он окупается.

← Глава 26. Схемы как источник истины Содержание Глава 28. Декларации и чужой код →