Глава 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 по умолчанию, но здесь подключаемый осознанно и только там, где он окупается.