
Привет, Хабр! Меня зовут Владимир, я фронтенд-разработчик в Rusprofile. Давайте поговорим о работе с исключениями.
Ситуация: пишешь код на JS, хочешь обработать исключение, пробрасываешь throw, ловишь его с помощью try-catch. Но добавим нюанс: пусть это нужно сделать для setTimeout. Эта функция использует окружения браузера – не JS-функция, асинхронная.
// Не сработает
try {
setTimeout(() => {
throw new Error('Ошибка');
}, 0);
} catch (e) {
// Этот код никогда не выполнится
console.error(e);
}
Загвоздка: catch не сможет поймать ошибку.
Этот пример – одна из нескольких особенностей JS, из-за которых я считаю обработку исключений “из коробки” на этом языке неудобной. Но есть и хорошие новости – для JS существуют альтернативные способы работы с исключениями, с которыми дела обстоят получше; например, паттерн “контейнерный тип”.
Давайте разбираться.
Это база
Итак, у нас в коде что-то может пойти не так (увы). Какие-то ситуации могут нарушать фундаментальные контракты системы – не закрыли скобку, зациклили функцию, переполнили память. Приложение ложится, и это в целом правильно.
Другие ситуации менее критичны для системы: вызвали не тот метод, обратились не к тому свойству объекта.
Ситуации второго типа мы называем исключениями, хотим уметь их находить и исправлять (обрабатывать), без падения приложения.
Как и в большинстве языков программирования, исключение в JS опираются на использование оператора throw, значения которой мы ловим с помощью конструкции try-catch.
Через throw можно описать все некритичные исключительные ситуации в коде. Значение throw может быть любым – строкой, числом, другой функцией.
Есть класс Error, который имеет множество подклассов и довольно удобный API, а также содержит полезную метаинформацию для отладки.
Есть финализаторы (finally), чтобы даже если у нас произошла ошибка, мы в любом случае могли выполнить код (закрыть соединение к БД, убрать лоадер и так далее). Внутри finally могут возникать новые исключения.
Что не так с исключениями на JS
Изначально JS создавался как встраиваемый язык программирования, который зависит от окружения, в нашем случае браузера. Язык синхронный by design; конструкция try-catch изначально была рассчитана на синхронный код.
Для примера из начала статьи неотловленное исключение двинется за пределы функции, “наверх”, сея на своем пути разрушения. Приложение упадет.
В какой-то момент в JS появился метод async/await – с его помощью мы можем сделать функцию асинхронной и внутри нее уже вызвать try-catch.
Есть класс AggregateError, который позволяет сгруппировать несколько ошибок, используется в специфических сценариях (например, Promise.allSettled), но не является мейнстримным решением для обработки исключений.
// Async / await, AggregateError
async function multipleErrors() {
const promises = [
Promise.reject(new Error('Ошибка 1')),
Promise.reject(new Error('Ошибка 2')),
Promise.resolve('Успех')
];
try {
const settled = await Promise.allSettled(promises);
const errors = settled.filter((r) => r.status === 'rejected').map((r) => r.reason);
if (errors.length) {
throw new AggregateError(errors, 'Несколько ошибок');
}
} catch (e) {
console.error(e); // ['Ошибка 1','Ошибка 2']
}
}
Но неудобств всё равно хватает.
В JS у нас нет возможности типизировать исключения. Для произвольной функции в коде мы не можем сказать, вернёт ли она исключение или нет. Да, у нас есть описания в JSDoc, и мы можем надеяться, что наша IDE туда заглянет и потому подскажет нам, что прописанную в JSDoc функцию нужно завернуть в try-catch.
/**
* @throws {RangeError} может быть исключение
*/
function onMathRandom() {
const number = Math.random();
// ...какая-то логика
if (number > 0.5) {
return number;
} else {
throw new RangeError('Ошибка');
}
}
Но это не железобетонно надежный вариант.
Если мы не отловили исключение, то дальше код не будет работать; приложение упадёт.
Ещё в JS отсутствует синтаксис типа catch (TypeError | RangeError), поэтому приходится делать вложенные try/catch или городить проверку типа в одном catch через instanceof. Это может стать проблемой в плохо организованном коде или в следствии плохих архитектурных решений.
Что делать: контейнеры
Нам на помощь могут прийти паттерны из мира функционального программирования – такие, как контейнерный тип. Мы можем создать контейнер по типу promise, и в нем обрабатывать исключения с помощью обычных return. Это обертка, которая добавляет контекст данным.
Контейнер не позволяет обратиться к данным напрямую. У него есть API, который возвращает методы, через которые мы потом получаем нужные нам данные.
Соответствующая функция всегда возвращает один тип – контейнер. Если мы не обработали исключение, оно всегда будет в контейнере и не пойдёт никуда дальше, разрушая всё на своём пути.
Ещё одно преимущество контейнерного типа – типизация на TS. Если мы используем контейнер, то нам в целом не нужно знать, как контейнер написан или даже как работает – при наведении на контейнер TS покажет всё нужное (какие данные вернет контейнер, какие там есть методы).
Пример: пусть у нас есть функция openFile.
function openFile(path: string) {
if (!path) return Result.onError('Укажите валидный путь');
return Result.onSuccess({ name: 'file', path });
}
openFile('/test.txt').then(console.log).catch(console.error);
Значение функции – строка. Если строки нет или она неправильная, то возвращаем контейнер с ошибкой.
В метод onError мы передаем сообщение “Укажите валидный путь”. Если мы можем открыть файл, то есть путь правильный, мы возвращаем контейнер с сообщением об успехе. Туда помещаем объект – имя файла и путь к нему.
Дальше мы вызываем эту функцию и передаем туда какую-то строку. У нас есть два метода: then и catch. В then мы передаем функцию, которая примет данные при успешном выполнении; catch обработает ошибку, если она произошла.
Таким образом, у нас не будет ситуации, что мы не обработали ни тот, ни другой метод. Нам не нужно это оборачивать в try-catch. Удобно!
С контейнерным типом мы не можем забыть обработать исключение – взаимодействуем с API контейнера.
Это отлично работает на TS: не нужно вчитываться в код, чтобы понять, что у значения есть контекст; всё подскажет IDE.
Из минусов: контейнерный тип бывает многословен, может выглядеть громоздко, напрашиваться на рефакторинг. Это поправимо; в этом случае на помощь могут прийти декораторы.
У тебя есть задача – придумай, как её решить
Работа с исключениями на JS может быть неудобной, “так сложилось исторически”. Но это обстоятельство на мой взгляд привносит в работу фронтенд-разработчика не только фрустрацию, но и дополнительный элемент творчества.
Варианты, как обойти неудобства существуют – контейнерный тип, а ещё константы. Например, как в функции indexOf() – если индекс не найден, нам вернется отрицательное число в виде -1, что является тоже своего рода обработкой исключительной ситуации.
Варианты есть, нужно только поискать; заглянуть в соседние языки программирования и паттерны оттуда; попробовать.
Исключения на JS – неудобно? Возможно. Повод узнать что-то новое? Определенно!
Комментарии (2)
Vindicar
04.09.2025 10:50Эта функция использует окружения браузера – не JS-функция, асинхронная.
Ну так почему не использовать async/await? Тогда и обычный try-catch сработает. Если речь идёт вот именно про setTimeout(), его тоже можно завернуть в промис.
Я соглашусь, что монада Either (а ваш контейнер - это оно) имеет свои преимущества, но пример-то можно было подобрать и понагляднее.
parus-lead
Хорошо описано. Кстати, очень полезный пример, поскольку несколько раз сам сталкивался с проблемами исключений на JS. Надо попробовать