Когда что-то разрабатываем, то нам часто необходимы собственные классы ошибок для разных вещей, которые могут пойти не так в наших задачах. Для ошибок при работе с сетью может понадобиться HttpError
, для операций с базой данных DbError
, для поиска – NotFoundError
и т.д.
Наши ошибки должны поддерживать базовые свойства, такие как message
, name
и, желательно, stack
. Но также они могут иметь свои собственные свойства. Например, объекты HttpError
могут иметь свойство statusCode
со значениями 404
, 403
или 500
.
JavaScript позволяет вызывать throw
с любыми аргументами, то есть технически наши классы ошибок не нуждаются в наследовании от Error
. Но если использовать наследование, то появляется возможность идентификации объектов ошибок посредством obj instanceof Error
. Так что лучше применять наследование.
По мере роста приложения, наши собственные ошибки образуют иерархию, например, HttpTimeoutError
может наследовать от HttpError
и так далее.
Расширение Error
В качестве примера рассмотрим функцию readUser(json)
, которая должна читать данные пользователя в формате JSON.
Пример того, как может выглядеть корректный json
:
let json = `{ "name": "John", "age": 30 }`;
Внутри будем использовать JSON.parse
. При получении некорректного json
он будет генерировать ошибку SyntaxError
. Но даже если json
синтаксически верен, то это не значит, что это будет корректный пользователь, верно? Могут быть пропущены необходимые данные. Например, могут отсутствовать свойства name
и age
, которые являются необходимыми для наших пользователей.
Наша функция readUser(json)
будет не только читать JSON-данные, но и проверять их («валидировать»). Если необходимые поля отсутствуют или данные в неверном формате, то это будет ошибкой. Но не синтаксической ошибкой SyntaxError
, потому что данные синтаксически корректны. Это будет другая ошибка.
Назовём её ошибкой валидации ValidationError
и создадим для неё класс. Ошибка этого вида должна содержать информацию о поле, которое является источником ошибки.
Наш класс ValidationError
должен наследовать от встроенного класса Error
.
Класс Error
встроенный, вот его примерный код, просто чтобы мы понимали, что расширяем:
// "Псевдокод" встроенного класса Error, определённого самим JavaScript
class Error {
constructor(message) {
this.message = message;
this.name = "Error"; // (разные имена для разных встроенных классов ошибок)
this.stack = <стек вызовов>; // нестандартное свойство, но обычно поддерживается
}
}
Теперь давайте унаследуем от него ValidationError
и попробуем новый класс в действии:
class ValidationError extends Error {
constructor(message) {
super(message); // (1)
this.name = "ValidationError"; // (2)
}
}
function test() {
throw new ValidationError("Упс!");
}
try {
test();
} catch(err) {
alert(err.message); // Упс!
alert(err.name); // ValidationError
alert(err.stack); // список вложенных вызовов с номерами строк для каждого
}
Обратите внимание: в строке (1)
вызываем родительский конструктор. JavaScript требует от нас вызова super
в дочернем конструкторе, так что это обязательно. Родительский конструктор устанавливает свойство message
.
Родительский конструктор также устанавливает свойство name
для "Error"
, поэтому в строке (2)
мы сбрасываем его на правильное значение.
Попробуем использовать его в readUser(json)
:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
// Использование
function readUser(json) {
let user = JSON.parse(json);
if (!user.age) {
throw new ValidationError("Нет поля: age");
}
if (!user.name) {
throw new ValidationError("Нет поля: name");
}
return user;
}
// Рабочий пример с try..catch
try {
let user = readUser('{ "age": 25 }');
} catch (err) {
if (err instanceof ValidationError) {
alert("Некорректные данные: " + err.message); // Некорректные данные: Нет поля: name
} else if (err instanceof SyntaxError) { // (*)
alert("JSON Ошибка Синтаксиса: " + err.message);
} else {
throw err; // неизвестная ошибка, пробросить исключение (**)
}
}
Блок try..catch
в коде выше обрабатывает и нашу ValidationError
, и встроенную SyntaxError
из JSON.parse
.
Обратите внимание, как мы используем instanceof
для проверки конкретного типа ошибки в строке (*)
.
Мы можем также проверить тип, используя err.name
:
// ...
// вместо (err instanceof SyntaxError)
} else if (err.name == "SyntaxError") { // (*)
// ...
Версия с instanceof
гораздо лучше, потому что в будущем мы собираемся расширить ValidationError
, сделав его подтипы, такие как PropertyRequiredError
. И проверка instanceof
продолжит работать для новых наследованных классов. Так что это на будущее.
Также важно, что если catch
встречает неизвестную ошибку, то он пробрасывает её в строке (**)
. Блокcatch
знает, только как обрабатывать ошибки валидации и синтаксические ошибки, а другие виды ошибок (из-за опечаток в коде и другие непонятные) он должен выпустить наружу.
Дальнейшее наследование
Класс ValidationError
является слишком общим. Много что может пойти не так. Свойство может отсутствовать или иметь неверный формат (например, строка как значение возраста age
). Поэтому для отсутствующих свойств сделаем более конкретный класс PropertyRequiredError
. Он будет нести дополнительную информацию о свойстве, которое отсутствует.
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
class PropertyRequiredError extends ValidationError {
constructor(property) {
super("Нет свойства: " + property);
this.name = "PropertyRequiredError";
this.property = property;
}
}
// Применение
function readUser(json) {
let user = JSON.parse(json);
if (!user.age) {
throw new PropertyRequiredError("age");
}
if (!user.name) {
throw new PropertyRequiredError("name");
}
return user;
}
// Рабочий пример с try..catch
try {
let user = readUser('{ "age": 25 }');
} catch (err) {
if (err instanceof ValidationError) {
alert("Неверные данные: " + err.message); // Неверные данные: Нет свойства: name
alert(err.name); // PropertyRequiredError
alert(err.property); // name
} else if (err instanceof SyntaxError) {
alert("Ошибка синтаксиса JSON: " + err.message);
} else {
throw err; // неизвестная ошибка, повторно выбросит исключение
}
}
Новый класс PropertyRequiredError
очень просто использовать: необходимо указать только имя свойства new PropertyRequiredError(property)
. Сообщение для пользователя message
генерируется конструктором.
Обратите внимание, что свойство this.name
в конструкторе PropertyRequiredError
снова присвоено вручную. Правда, немного утомительно – присваивать this.name = <class name>
в каждом классе пользовательской ошибки. Можно этого избежать, если сделать наш собственный «базовый» класс ошибки, который будет ставить this.name = this.constructor.name
. И затем наследовать все ошибки уже от него.
Давайте назовём его MyError
.
Вот упрощённый код с MyError
и другими пользовательскими классами ошибок:
class MyError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}
class ValidationError extends MyError { }
class PropertyRequiredError extends ValidationError {
constructor(property) {
super("Нет свойства: " + property);
this.property = property;
}
}
// name корректное
alert( new PropertyRequiredError("field").name ); // PropertyRequiredError
Теперь пользовательские ошибки стали намного короче, особенно ValidationError
,
так как мы избавились от строки "this.name = ..."
в конструкторе.
Обёртывание исключений
Назначение функции readUser
в приведённом выше коде – это «чтение данных пользователя». В процессе могут возникнуть различные виды ошибок. Сейчас у нас есть SyntaxError
и ValidationError
, но в будущем функция readUser
может расшириться и, возможно, генерировать другие виды ошибок.
Код, который вызывает readUser
, должен обрабатывать эти ошибки.
Сейчас в нём используются проверки if
в блоке catch
, которые проверяют класс и обрабатывают известные ошибки и пробрасывают дальше неизвестные. Но если функция readUser
генерирует несколько видов ошибок, то мы должны спросить себя: действительно ли мы хотим проверять все типы ошибок поодиночке во всех местах в коде, где вызывается readUser
?
Часто ответ «Нет»: внешний код хочет быть на один уровень выше всего этого. Он хочет иметь какую-то обобщённую ошибку чтения данных. Почему именно это произошло – часто не имеет значения (об этом говорится в сообщении об ошибке). Или даже лучше, если есть способ получить подробности об ошибке, но только если нам это нужно.
Итак, давайте создадим новый класс ReadError
для представления таких ошибок. Если ошибка возникает внутри readUser
, мы её перехватим и сгенерируем ReadError
. Мы также сохраним ссылку на исходную ошибку в свойстве cause
. Тогда внешний код должен будет только проверить наличие ReadError
.
Этот код определяет ошибку ReadError
и демонстрирует её использование в readUser
и try..catch
:
class ReadError extends Error {
constructor(message, cause) {
super(message);
this.cause = cause;
this.name = 'ReadError';
}
}
class ValidationError extends Error { /*...*/ }
class PropertyRequiredError extends ValidationError { /* ... */ }
function validateUser(user) {
if (!user.age) {
throw new PropertyRequiredError("age");
}
if (!user.name) {
throw new PropertyRequiredError("name");
}
}
function readUser(json) {
let user;
try {
user = JSON.parse(json);
} catch (err) {
if (err instanceof SyntaxError) {
throw new ReadError("Синтаксическая ошибка", err);
} else {
throw err;
}
}
try {
validateUser(user);
} catch (err) {
if (err instanceof ValidationError) {
throw new ReadError("Ошибка валидации", err);
} else {
throw err;
}
}
}
try {
readUser('{bad json}');
} catch (e) {
if (e instanceof ReadError) {
alert(e);
// Исходная ошибка: SyntaxError:Unexpected token b in JSON at position 1
alert("Исходная ошибка: " + e.cause);
} else {
throw e;
}
}
В приведённом выше коде readUser
работает так, как описано – функция распознаёт синтаксические ошибки и ошибки валидации и выдаёт вместо них ошибки ReadError
(неизвестные ошибки, как обычно, пробрасываются).
Внешний код проверяет только instanceof ReadError
. Не нужно перечислять все возможные типы ошибок
Этот подход называется «обёртывание исключений», потому что мы берём «исключения низкого уровня» и «оборачиваем» их в ReadError
, который является более абстрактным и более удобным для использования в вызывающем коде. Такой подход широко используется в объектно-ориентированном программировании.
Итого
- Мы можем наследовать свои классы ошибок от
Error
и других встроенных классов ошибок, но нужно позаботиться о свойствеname
и не забыть вызватьsuper
. - Мы можем использовать
instanceof
для проверки типа ошибок. Это также работает с наследованием. Но иногда у нас объект ошибки, возникшей в сторонней библиотеке, и нет простого способа получить класс. Тогда для проверки типа ошибки можно использовать свойствоname
. - Обёртывание исключений является распространённой техникой: функция ловит низкоуровневые исключения и создаёт одно «высокоуровневое» исключение вместо разных низкоуровневых. Иногда низкоуровневые исключения становятся свойствами этого объекта, как
err.cause
в примерах выше, но это не обязательно.