Исключения в JavaScript

Как всегда, нас не очень интересует вопрос, как работать с ошибками в джаваскрипте. Потому что горазо важнее вопрос зачем. К тому моменту, когда вы встречаетесь в инструкциями try-catch и throw, вы уже решили больше сотни задач и как-то нормально обходились бросания ошибок.

Наш план

  • Примеры знакомых ошибок
  • Как бросать и ловить ошибки с точки зрения синтаксиса JS
  • Понятие ошибки и исключения
  • Лирическое отступление про null и undefined
  • Пример синхронного исключения при работе с JSON
  • Асинхронные ошибки

Какие ошибки мы уже встречали?

В основном ошибки происходили, когда вы делали что-то не так. Я приведу несколько примеров.

Иногда мы случайно обращались к несуществующему ключу объекта или несуществующему индексу массива и получали Cannot read properties of undefined (reading 'toUpperCase').

const fruits = ["apple", "peach", "orange"]; fruits[3].toUpperCase();

Иногда мы пытались переприсвоить значение константы и получали Assignment to constant variable.

const x = 5; x++;

Иногда мы вызывали reduce без начального значения на пустом массиве и получали Reduce of empty array with no initial value.

[].reduce((a, b) => a + b);

Что объединяет все эти примеры? Их можно было избежать. Можно проверять, что индекс существует, сравнивая его с длиной массива. У редьюса нужно всегда указывать начальное значение. А с const нужно просто быть внимательнее :)

Как бросать и ловить ошибки?

Бросаем. В любой функции вы можете написать throw и какое-то значение после него. В джаваскрипте бросить можно все что угодно. Но лучше всего бросать объект ошибки (Error или его классы-наследники). Если мы бросаем объект ошибки, то в нем автоматически создается поле stack, по которому мы можем определить, где именно произошла ошибка.

После вызова инструкции throw выполнение кода останавливается, мы досрочно завершаем все функции, которые были в данный момент запущены и попадаем в ближайший catch.

Ловим. Также в любом месте вы можете обернуть произвольный кусок кода в инструкцию try-catch. Тогда если внутри какой-то функции, которая вызывалась в результате выполнения этого кода, была брошена ошибка мы поймаем ее в блоке catch.

function foo() { console.log(1); throw new Error("oops"); console.log(2); } function bar() { console.log(3); foo(); console.log(4); } function main() { try { console.log(5); bar(); console.log(6); } catch (e) { console.log(e.message); } } main(); // В консоль выведутся 5 3 1 oops // e.stack будет содержать примерно такую строку // Error: oops // at foo (<anonymous>:2:9) // at bar (<anonymous>:6:3) // at main (<anonymous>:12:5)

После того, как мы бросили ошибку в foo не смысла не только продолжать выполнять foo, но и продолжать выполнять bar. Соотвественно, мы сразу оказываемся в блоке catch.

Ошибки и исключения

В разных языках программирования используются термины exception (исключение) и error (ошибка), которые значат примерно одно и то же. Мне ближе термин exception, потому что он лучше объясняет, что происходит. А происходит исключительная ситуация.

Как я писал выше, если вы попытались вызвать метод у undefined, потому что не проверили, что ключ существует, то это не исключение, а баг. Баги нужно исправлять и отлавливать написанием тестов.

Например, если у объекта потенциально может не быть какого-то ключа, можно добавить проверку через if или использовать oprional chaining (?.).

В других языках программирования эксепшены встречаются даже в тех местах, где джаваскрипт вас прощает.

Java бросает ошибку, если вы обратились по неправильному индексу. В джаваскрипте вы бы получили undefined.

int[] arr = {2, 3, 5, 7, 11}; int last = arr[5]; | Exception java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5 | at (#2:1)

Python бросает ошибку, если вы поделили на ноль. В джаваскрипте вы бы получили Infinity.

x = 10 y = 0 div = x / y Traceback (most recent call last): File "<python-input-3>", line 3, in <module> div = x / y ~~^~~ ZeroDivisionError: division by zero

История про null и undefined

Кстати, наличие в языке JavaScript двух значаений, означающих отсутствие значения (null и undefined) обязано тем, что в момент создания языка автору хотелось одновременно две вещи: предусмотреть совместимость с джавой (нужен null) и не реализовывать механизм ловли ошибок (так появился undefined).

Эксперты расскажут вам, что null и undefined обладают разной семантикой. Мол, undefined возникает, когда мы обращаемся к чему-то несуществующему (переменной, аргументу, индексу, ключу), и не должен использоваться программистом. А null мы должны присваивать явно, когда хотим показать, что чего-то нет, и мы его устанавливаем осознанно.

В целом, это хорошее правило, но сам язык очень часто уравнивает эти значения. Например, при сравнении через двойное равно (==) null и undefined равны друг другу и никому больше. А optional chaining и nullish coalescing operator опять же работают одинаково для null и undefined.

Когда мы пишем на реакте, вполне осознанно можно реализовать компонент, где мы явно должны передать undefined, потому что onClick ожидает функцию или ничего. Передать null нельзя, это будет ошибкой.

function Link({ children, url, withMetrics }) { function sendMetrics() { // do something } return ( <a href={url} onClick={withMetrics ? sendMetrics : undefined}> {children} </a> ); }

Когда возникают исключения?

Исключения возникают в тех случаях, когда вы предусмотрели все, что могли, но проблема возникла не по вашей вине. Например, вы хотите прочитать файл, а его нет. Вы хотите отправить сетевой запрос, а интернет отвалился. Вы ожидали, что данные в файле будут в определенном формате, а там какая-то фигня.

Особенность джаваскрипта в том, что практически все такие операции возникают в результате асинхронных взаимодействий, которые в джаваскрипте исторически реализовывались через колбэки. С появлением синтаксиса async-await (ES2017) try-catch получил вторую жизнь и широкое распространение.

JSON

До появления синтаксиса async-await самым популярным примером использования try-catch являлся кейс с парсингом джейсона.

JSON — это аббревиатура, в названии которой три буквы из четырех являются ложью. Расшифровывающийся как JavaScript Object Notation этот формат — в общем случае — не имеет отношения ни к джаваскрипту, ни к объектам (точнее имеет отношение не только к объектам и не только к джавскрипту).

Notation — по-русски, видимо, нотация — это система записи. Приведу несколько примеров, когда используется слово натация:

  • Нотация Лейбница из матанализа (dx/dy, интеграл в виде вытянутой буквы S)
  • Экспоненциальная запись (scientific notation) для длинных чисел (2 350 000 000 = 2,35 × 10⁹)
  • Современная музыкальная нотация (нотная запись)
  • Химические формулы (H₂O и C₂H₆O)

Джейсон позволяет в виде строки описать какое-то значение. В первую очередь для того, чтобы передавать его между приложениями. И хотя приложения могут быть написаны не на джаваскрипте, JSON используется повсеместно.

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

В джаваскрипте получить эту строчку можно, вызвав фунцию JSON.stringify.

JSON.stringify(["1", "2", "3"]) === '["1","2","3"]' JSON.stringify({x: 1, y: 2}) === '{"x":1,"y":2}' JSON.stringify(123) === '123' JSON.stringify("123") === '"123"' // можно получить красивую запись, кстати JSON.stringify({ firstName: "Max", lastName: "Sinyakov" }, null, 2) === `{ "firstName": "Max", "lastName": "Sinyakov" }`

Ошибки при работе с JSON

Первый раз вы можете получить исключение, попытавшись преобразовать в джейсон значение типа bigint (длинные числа). Основная проблема в том, как его закодировать. Если записать как число, то принимающая сторона может сломаться, если число окажется слишком длинным. Если записать в виде строки, то принимающая сторона никогда не догадается, что это был bigint.

JSON.stringify(239n); // TypeError: Do not know how to serialize a BigInt

Но это опять пример того, когда вы сами создаете себе проблемы. Просто не передавайте bigint в эту функцию. И все будет окей.

Главная проблема возникает тогда, когда мы пытаемся доверять другим людям. Нам говорят, вот строка, в ней JSON, ты можешь распарсить его и получить джавасриптовое значение.

const json1 = `{"x":1,"y":2}`; const json2 = `{'x':1,'y':2}`; JSON.parse(json1); // все окей JSON.parse(json2); // подстава

При попытке распарсить json2 мы получаем SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2). Вот так и доверяй людям. Что с этим можно сделать? Ничего. Только обернуть в try catch.

Пример с localStorage

В браузере есть возможность привязать к сайту данные, которые будут доступны даже после перезапуска браузера. Один из способов — использование Web Storage API (также известный как localStorage).

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

function restoreFavoriteIds() { const favoriteIdsJson = localStorage.getItem("favoriteIds"); if (favoriteIdsJson === null) { return []; // 🤷🏻‍♂️ } try { // 🤞🏼 Пожалуйста, пусть там будет валидный джейсон, как я ожидаю const favoriteIds = JSON.parse(favoriteIdsJson); if (Array.isArray(favoriteIds) && favoriteIds.every(id => typeof id === "number") ) { return favoriteIds } return []; // 🤷🏻‍♂️ } catch { return []; // 🤷🏻‍♂️ } }

В трех случаях мы вынуждены вернуть пустой массив, потому что не смогли получить нужные данные. И только в одном из них мы используем try catch.

  • Если localStorage изначально не содержит нужное значение, мы это поймем, сравнив ркезультат с null
  • Если такое значение есть и мы его смогли прочитать, то с помощью стандартных методов джаваскрипта мы можем убедиться, что там был закодирован массив чисел.
  • А вот если этот массив был закодирован неправильно и там лежал не JSON, а какая-то фигня или даже тот же массив, но в другом формате, то вызов JSON.parse бросит ошибку и в блоке catch мы сможем ее обработать (вернуть пустой массив).

На самом деле хорошей идеей было бы обернуть вообще весь этот код в try catch, поскольку сам объект localStorage тоже может отсутствовать. Например, в Safari в приватном режиме (incognito mode), localStorage не работает.

Асинхронные ошибки

На этом примеры ловли ошибок с помощью try catch в пользовательском коде обычно заканчиваются, потому что из-за однопоточности и работы через Event Loop, все остальные операции, где мы взаимодействуем с внешним миром, работают через колбэки.

Метод fetch возвращает объект класа Promise. Если запрос ушел успешно, мы попадем в первый колбэк. Если запрос не ушел (например, нет интрнета), мы попадем во второй колбэк.

fetch("https://maxcode.dev/").then( () => { /* браузер смог отправить запрос */ }, () => { /* например, интернет не работает */ } );

Если бы fetch работал синхронно, мы могли бы использовать try catch. На самом деле мы можем использовать и сейчас, но это особый try catch, который работает внутри async-функций, и тогда от then надо будет избавиться. А мы сейчас как раз обсуждаем, почему до синтаксиса async-await try-catch нам бы не помог.

Более древние API работают на колбэках без промисов. Например, в браузере можно прочитать содержимое файла:

const reader = new FileReader(); reader.onload = function(event) { console.log(event.target.result); }; reader.onerror = function(event) { console.log("файл оказался не текстовым, а картинкой, например"); }; reader.readAsText(file);

В этом случае мы сначала как бы подписываемся на событие ошибки, присваивая в поле onerror функцию, которая дожна вы полниться в случае, если файл не прочитался. А затем вызываем метод readAsText, который запускает процесс чтения. Этот вызов ничего не возвращает. Но когда файл прочитается, сработает колбэк из поля onload.

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

Вот только внешней системой в этом случае выступает программист, использующий язык. А сам джаваскрипт со своей стороны делает все, чтобы просигнализировать: внешний мир опять делает что-то не так. И бросает эксепшен.