Если вы пришли сюда только ради ответа и вам не интересны рассуждения - листайте вниз :)
Как все начиналось
Для начала, давайте вспомним, а как вообще ловят ошибки в js, будь то браузер или сервер. В js есть конструкция try...catch
.
try {
let data = JSON.parse('...');
} catch(err: any) {
// если произойдет ошибка, то мы окажемся здесь
}
Это общепринятая конструкция и в большинстве языков она есть. Однако, тут есть проблема (и как окажется дальше - не единственная), эта конструкция "не будет работать" для асинхронного кода, для кода который был лет 5 назад. В те времена, в браузере использовали для Ajax запроса XMLHttpRequest
.
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com', true);
xhr.addEventListener('error', (e: ProgressEvent<XMLHttpRequestEventTarget>) => {
// если произойдет ошибка, то мы окажемся здесь
});
Тут используется механизм подписки на событие возникновения ошибки. В данном случае, переменная e
является событием, фактически мы ушли от настоящей ошибки и закрылись некоторой абстракцией, за которой спрятана настоящая ошибка, доступа к которой у нас нет.
В NodeJS с самого начала продвигалась концепция Error-First Callback, эта идея применялась для асинхронных функций, например, для чтения файла. Смысл ее в том, чтобы первым аргументом передавать в функцию обратного вызова ошибку, а следующими аргументами уже получаемые данные.
import fs from 'fs';
fs.readFile('file.txt', (err, data) => {
if (err) {
// обработка ошибки
}
// если все хорошо, работаем с данными
});
Если мы посмотрим какой тип имеет переменная err
, то увидим следующее:
interface ErrnoException extends Error {
errno?: number | undefined;
code?: string | undefined;
path?: string | undefined;
syscall?: string | undefined;
}
Тут действительно находится ошибка. По сути, это тот же способ, что и выше, только в этом случает мы получаем объект Error
.
Через некоторое время, в Javascript появились Promise
. Они, безусловно, изменили разработку на js к лучшему. Ведь никто* никто не любит городить огромные конструкции из функций обратного вызова.
fetch('https://api.example.com')
.then(res => {
// если все хорошо, работаем с данными
})
.catch(err => {
// обработка ошибки
});
Несмотря на то, что внешне этот пример сильно отличается от первого, тем не менее, мы видим явную логическую связь. Очевидно, что разработчики хотели сделать похожую на try...catch
конструкцию. Со временем, появился еще один способ обработать ошибку в асинхронном коде. Этот способ, по сути, является лишь синтаксическим сахаром для предыдущего примера.
try {
const res = await fetch('https://api.example.com');
// если все хорошо, работаем с данными
} catch(err) {
// обработка ошибки
}
Также, конструкция try...catch
позволяет ловить ошибки из нескольких промисов одновременно.
try {
let usersRes = await fetch('https://api.example.com/users');
let users = await usersRes.json();
let chatsRes = await fetch('https://api.example.com/chats');
let chats = await chatsRes.json();
// если все хорошо, работаем с данными
} catch(err) {
// обработка ошибки
}
Вот, замечательный вариант ловли ошибок. Любая ошибка которая возникнет внутри блока try
, попадет в блок catch
и мы точно её обработаем.
А точно ли обработаем?
Действительно, а правда ли, что мы обработаем ошибку, или всего лишь сделаем вид? На практике, скорее всего, возникнувшая ошибка будет просто выведена в консоль или т.п. Более того, при появлении ошибки*, интерпретатор прыгнет в блок catch
, где не мы, не TypeScript не сможет вывести тип переменной, попавшей туда (пример - возврат с помощью Promise.reject
), после чего, произойдет выход из функции. То есть, мы не сможем выполнить код который находится в этом же блоке, но который расположен ниже функции, внутри которой произошла ошибка. Конечно, мы можем предусмотреть такие ситуации, но сложность кода и читаемость вырастут многократно.
Как быть?
Давайте попробуем использовать подход, предлагаемый разработчиками одного небезызвестного языка.
let [users, err] = await httpGET('https://api.example.com/users');
if (err !== null) {
// обработка ошибки
}
// продолжаем выполнение кода
Возможную ошибку мы держим всегда рядом с данными, возвращаемыми из функции, что намекает нам на то, что переменную err
желательно проверить.
Пример для вызова нескольких функций возвращающих Promise
.
let err: Error,
users: User[],
chats: Chat[];
[users, err] = await httpGET('https://api.example.com/users');
if (err !== nil) {
// обработка ошибки
}
[chats, err] = await httpGET('https://api.example.com/chats');
if (err !== nil) {
// обработка ошибки
}
Конечно, мы можем, как и прежде, просто выходить из функций при появлении ошибки, но если, все таки, появляется необходимость отнестись к коду более ответственно, мы без труда можем начать это делать.
Давайте рассмотрим как можно реализовать такую функцию и что нам вообще нужно делать. Для начала, давайте определим тип PairPromise
. В данном случае, я решил использовать null
если результата или ошибки нету, так как он просто короче.
type PairPromise<T> = Promise<[T, null] | [null, Error]>;
Определим возможные возвращаемые ошибки.
const notFoundError = new Error('NOT_FOUND');
const serviceUnavailable = new Error('SERVICE_UNAVAILABLE');
Теперь опишем нашу функцию.
const getUsers = async (): PairPromise<User[]> => {
try {
let res = await fetch('https://api.example.com/users');
if (res.status === 504) {
return Promise.resolve([null, serviceUnavailable]);
}
let users = await res.json() as User[];
if (users.length === 0) {
return Promise.resolve([null, notFoundError]);
}
return Promise.resolve([users, null]);
} catch(err) {
return Promise.resolve([null, err]);
}
}
Пример использования такой функции.
let [users, err] = await getUsers();
if (err !== null) {
switch (err) {
case serviceUnavailable:
// сервис недоступен
case notFoundError:
// пользователи не найдены
default:
// действие при неизвестной ошибке
}
}
Вариантов применения данного подхода обработки ошибок очень много. Мы сочетаем удобства конструкции try...catch
и Error-First Callback, мы гарантированно поймаем все ошибки и сможем удобно их обработать, при необходимости. Как приятный бонус - мы не теряем типизацию. Также, мы не скованы лишь объектом Error
, мы можем возвращать свои обертки и успешно их использовать, в зависимости от наших убеждений.
Очень интересно мнение сообщества на эту тему.
Комментарии (59)
dpereverza
22.06.2022 16:38+5Вариант с Go'шной обработкой ошибок, заставляет нас проверить на ошибки результат. Но с той же легкостью мы можем этого и не делать)
Ленивые разработчики будут писать что-то типа
[chats] = await httpGET('https://api.example.com/chats');
Но на CodeReview такие косяки будет заметнее, и это хорошо.
А вот что плохо, так это загрязнение кода от этих постоянных проверок.
Есть ФП подход к обработке ошибок, он сложнее, но если привыкнуть то гуд.
https://habr.com/ru/post/457098/asvxyz Автор
22.06.2022 17:28-2Да, такой подход позволяет опускать ошибки (как и в го, кстати), но позволяет меньше отвлекаться на этапе прототипирования.
По поводу «плохо» - если может возникнуть ошибка, то она возникнет) Это первое. Если мы не хотим проверять ошибки(загрязнять код(c)), то можно как в случае с try…catch замести все под коврик и просто вывести пользователю “sorry…”
SergeiMinaev
22.06.2022 17:10Для начала, давайте вспомним, а как вообще ловят ошибки в js, будь то браузер или сервер. В js есть конструкция
try...catch
.Несмотря на то, что даже в MDN фигурирует именно слово "ошибки", мне кажется более уместным всё-таки называть это исключениями. В англ. оригинале как раз используется слово "exception".
UPD: Имею в виду, что ошибки бывают восстановимыми и невосстановимыми. Во втором случае, если не делать try/catch, то программа падает и это называется исключением. А в конце этой статьи как раз приводится пример работы с восстановимыми ошибками, что в последнее время считается более правильным.
asvxyz Автор
22.06.2022 17:21-2На самом деле, в реальном (более низком уровне) мире, граница между ошибкой и исключением очень размыта.
monochromer
22.06.2022 17:32+2Позанудствую.
if (users.length === 0) { return Promise.resolve([null, notFoundError]); }
Нулевая длина списка вряд ли должна описываться ошибкой notFoundError. Ведь коллекция `users` есть. Если бы пользователь пошёл по пути `/users/1/`, а пользователя c `id = 1` нет, то тогда можно отдать notFoundError.
Из async-функции разве не достаточно возвращать просто массив `[data, error]` без оборачивания в
Promise.resolve
?asvxyz Автор
22.06.2022 18:48Так написано для большей наглядности. Да и мне так больше нравится (субъективно).
Что касается ‘not found’ , действительно, это притянутый за уши пример, тут может быть любая другая ошибка.
return
22.06.2022 18:29+5Далеко не всегда нужно обрабатывать ошибки и очень часто ошибка, брошенная через throw вполне себе может привести к 500 ошибке и записана куда-нибудь в лог и это будет правильно.
Все удобство в try..catch в том, чтобы отловить только те ошибки, на которые ты должен как-то специфически отреагировать. А остальные - ну а как ты их нормально обработаешь? Пусть себе ловит какой-то общий обработчик, который запишет в лог, а юзеру скажет сорян. Увидел в логе необработанную ошибку, понял, что такой ситуации можно избежать — делаешь catch и именно ее и ловишь, как-то так ????♂️
asvxyz Автор
22.06.2022 18:51-1Для описанного вами случая можно использовать “switch default”
return
22.06.2022 18:53+4А зачем, когда я просто не хочу смотреть на ошибку ни на одном из уровней, которых может быть дофига
asvxyz Автор
22.06.2022 19:06-2Такой способ позволяет обработать разумные ошибки, а не просто написать в лог. Но действительно, фронтенд приучает людей писать код расслаблено, просто поймать ошибку через catch и больше ничего не делать. На практике, в сложных системах, это более чем разумно. Не всегда нам нужно просто прекращать идти по сценарию. Представь, что у нас есть много сервисов и любой из них может «пропасть» в любой момент, это не повод прекращать работу всего проекта.
return
22.06.2022 19:15+3Прекрасно представляю, если жить без сервиса можно - вполне себе ок обработать ошибки от него. Если важен и без него никак (обычно это как раз такие) - то сорян, никакой обработки делать не нужно, пусть ловится глобально.
Все это надо держать в балансе, заставлять разработчика проверять ошибки всегда — неправильно
lair
23.06.2022 01:55+3Такой способ позволяет обработать разумные ошибки, а не просто написать в лог.
… а обычный catch не позволяет?
asvxyz Автор
23.06.2022 13:43Позволяет!
Судя по всем показателям (комменты, карма, рейтинг), люди восприняли мой текст, как призыв все бросить и безоговорочно начать использовать такой подход вообще везде. Вероятно, виноват мой стиль неопытного повествователя (первая публикация). Но идея такого подхода, фактически, не заменить, но дополнить стандартный подход. Всего навсего, ещё один инструмент, для некоторых ситуаций. Действительно, если логика приложения не требует такого подхода, то и использовать его не обязательно.
lair
23.06.2022 14:31+1Но идея такого подхода, фактически, не заменить, но дополнить стандартный подход
Когда в системе два подхода к обработке ошибок, программисты начинают их путать.
asvxyz Автор
23.06.2022 14:41-1Я выше приводил примеры такого как ошибки обрабатываются в случает XMLHttpRequest и readFile, в первом случае - подписка на событие ошибки, второй - ошибка передаётся первым аргументом в колбеке. Вообще забавна такая реакция сообщества, такое ощущение, что никто не знает про вышеописанные способы/приёмы. Вариант который предлагаю я, по сути, синтаксический сахар для ErrorFirstCallback. С трудом верится, что люди не знают такие вещи, видимо, такие люди просто более активны в комментариях…
lair
23.06.2022 14:44Я выше приводил примеры такого как ошибки обрабатываются в случает XMLHttpRequest и readFile, в первом случае — подписка на событие ошибки, второй — ошибка передаётся первым аргументом в колбеке.
Это называется "легаси". И это как раз и неудобно, что для обработки ошибок используются разные подходы.
asvxyz Автор
23.06.2022 14:52Подход у XMLHttpRequest - показывает, что жизнь может быть чуть сложнее. С readFile, действительно, сейчас есть возможность использовать вариант с промисом. Но старый вариант позволял выполнить определенный код не теряя "контекст".
lair
23.06.2022 15:04Подход у XMLHttpRequest — показывает, что жизнь может быть чуть сложнее.
Так это неудобно же. Наличие нескольких разных способов обработать ошибку — неудобно (потому что лишает код консистентности).
asvxyz Автор
23.06.2022 15:12И да, и нет. XMLHttpRequest имеет много разных событий: error, abort, timeout... Это как раз пример того, что ошибок может быть много и разных, и специфика этих ошибок в том, что их нужно по разному обрабатывать. Нас же не смущает, то, что мы используем колбеки для подписки на события. Timeout, abort, error - тоже события. С другой стороны, с "религиозной" точки зрения - "Это другое!".
lair
23.06.2022 15:18+1Это как раз пример того, что ошибок может быть много и разных, и специфика этих ошибок в том, что их нужно по разному обрабатывать.
Нет, это пример того, что бывают события, которые отражают ошибку.
специфика этих ошибок в том, что их нужно по разному обрабатывать
Ну так ошибки и в catch можно по-разному обрабатывать.
Нас же не смущает, то, что мы используем колбеки для подписки на события.
Если эти события нужны только для поддержания асинхронии — смущает.
Надо разделять ошибку как результат процесса (тогда ошибок больше возникнуть не может, можно ее вернуть из метода, как в вашем примере, или бросить как в try-catch), или ошибку как событие в процессе (они могут возникнуть еще потом, процесс не остановился, тогда ее нельзя вернуть, не годится ни try-catch, ни ваш подход).
asvxyz Автор
23.06.2022 15:34-2Ну так ошибки и в catch можно по-разному обрабатывать.
Да, но тут ты на уровне интерфейса (названия событий) понимаешь какие могут быть ошибки.
Возвращаемся к
try...catch
try { ... let data = await getDataFromCache(); if (!data) { // если в кэше этих данных нет data = await getDataFromDB(); } ... } catch(e) { // здесь может быть ошибка из кэша или db }
Иначе
let data, err; [data, err] = await getDataFromCache(); if (err !== null) { // например, кэш не успел подняться после // перезагрузки, это не повод идти в catch, // можно побробовать взять из базы или что то еще }
Да, можно использовать только
try...catch
, но...try { let x = 5; throw new Error(); } catch(e) { console.log(x); // Uncaught ReferenceError: x is not defined }
mayorovp
23.06.2022 15:38+1В вашем первом примере общая обработка для двух источников ошибки — это преимущество. А если надо разделить обработку — никто не мешает написать два разных блока try…catch
Что же до ReferenceError — да, не вполне удобно, но способ обхода общеизвестен. Всего-то надо объявить переменную блоком выше.
asvxyz Автор
23.06.2022 15:39-2То есть, все таки, лишний код можно писать, но только тогда, когда его ты сам благословил?
asvxyz Автор
23.06.2022 15:51В комментах были возмущения на тему того, что это лишний код, лишние проверки... Хоть ты это не писал, просто у меня накопилось) не наезд, ни в коем случае)
Благословил - я имел ввиду, что когда другие пишут 'лишний' код, это очень плохо, нужно минусовать. Если я пишу - это правильно, я знаю как лучше.
Вот так, грубо говоря.
mayorovp
23.06.2022 15:54Повторюсь:
общая обработка для двух источников ошибки — это преимущество
Согласно моему опыту, гораздо чаще приходится обрабатывать ошибки совместно, а не раздельно. Потому и оптимизируется именно совместная обработка ошибок.
asvxyz Автор
23.06.2022 15:57-2Чаще / реже, не значит, что нужно использовать подход обозначенный в статье, так же как и не значит, что не нужно использовать общий подход. Это просто еще один способ (по моему мнению более прогрессивный), не более того. Ну и "субъективно" для меня, читаемость такого кода лучше
lair
23.06.2022 15:38Да, но тут ты на уровне интерфейса (названия событий) понимаешь какие могут быть ошибки.
Нет. Я понимаю, какие могут быть события. А все ошибки — это все так же
error
. Вы можете сказать, какие ошибки могут там быть?Да, можно использовать только try...catch, но...
… а что вам мешает использовать больше одного try-catch?
asvxyz Автор
23.06.2022 15:55-2Разная специфика ошибок, требующая по-разному реагировать.
Кажется, что я столкнулся с 'религиозной' догмой...
asvxyz Автор
23.06.2022 16:07Серьезно? Я говорю, что можно применять разные подходы в зависимости от ситуации. Я не ограничиваюсь лишь своим подходом, меня не пугает ни try...catch, не onError, ни Error-First-Callback. Пока мне не слили рейтинг с кармой я не минусовал людей с другой точкой зрения, а общался. Но в тоже время, меня решили заминусовать. Так где же у меня догма?
lair
23.06.2022 16:12Разная специфика ошибок, требующая по-разному реагировать.
Так как раз это прекрасно покрывается разными try-catch и дифференцированной обработкой ошибок в них. В чем проблема?
asvxyz Автор
23.06.2022 16:23Как я писал выше, способ описанный в статье, позволяет обрабатывать ошибки по мере их поступления (возможного). Что касается catch, действительно, он ловит все ошибки и мы спокойно их все разгребем. Проблема лишь в том, что мы все это делаем в одной куче.
lair
23.06.2022 16:28Как я писал выше, способ описанный в статье, позволяет обрабатывать ошибки по мере их поступления (возможного).
А я думал, он их обрабатывает после возврата из метода?...
Проблема лишь в том, что мы все это делаем в одной куче.
Ну так не делайте в одной куче, делайте столько try-catch, сколько вам надо.
asvxyz Автор
23.06.2022 16:32Ну вот мое мнение, как раз в том и состоит, что по отдельности обрабатывать ошибки удобнее (по моему мнению, два
try-catch
в одном блоке уже перебор ) способом из статьи, если нужно все вместе -catch
lair
23.06.2022 17:31по моему мнению, два try-catch в одном блоке уже перебор
Но почему?
А еще понимаете ли в чем дело, вызываемый код не знает, как его будут использовать. И как ему возвращать ошибки?..
asvxyz Автор
23.06.2022 18:17-2Естественно, что не знает. Точно также, как и код возвращающий объект типа.
type Result = { status: boolean; message: string; }
lair
23.06.2022 18:18Так и как же вы предлагаете вызываемому коду возвращать ошибки, учитывая, что у вас две разных парадгмы в зависимости от того, как код вызывается?
asvxyz Автор
23.06.2022 16:36-2Кстати, если попытаться отвлечься от войны, можно увидеть, что наш разговор превратился в спор похожий на ситуацию "табуляция против пробела"... =)
lair
23.06.2022 17:36И вот я вам по личному опыту могу сказать, что проект в котором в одном файле табы, а в другом — пробелы, намного хуже того, где только табы, или только пробелы (и не важно, какой стиль мне больше нравится).
Deka87
22.06.2022 21:01+3В заголовке указан JavaScript, а в примерах TypeScript. Наверное, не стоит подразумевать, что все JavaScript разработчики пишут на последнем. Тем более, что эту статью могут найти через много лет, когда TS может быть уже не таким популярным, и это введёт в заблуждение.
Ilusha
23.06.2022 00:53-1Думаю, что стоит идти в ногу со временем. И стоит заниматься популяризацией TS. Тем более сейчас это происходит настолько органично, что не вызывает ни отторжения.
А через много лет кто-нибудь напишет новую статью.
Deka87
23.06.2022 01:13+5Вероятно также думали разработчики, которые писали на jQuery в своё время (не смотря на всю разницу между jQuery и TS). Предлагаю все таки придерживаться каких-то правил и указывать TS, если речь идёт о TS, и JS, если речь идёт о чистом JS. Иначе многие начинающие разработчики могут не понять неожиданный синтакс.
lair
… и что гарантирует, что возвращенная ошибка действительно будет обработана, а не будет так, что вызывающий код просто ее отбросит и вернет
users
?halfcupgreentea
Тут может помочь TS: пока не будет проверен тип
err
, типusers
будетUsers | null
Напримерasvxyz Автор
Действительно, ничто не может заставить разработчика обязательно обрабатывать все ошибки. Более того, такой способ позволяет не извлекать ошибку вообще. Но, такой код стимулирует разработчика озаботиться обработкой ошибки. В случае с try…catch, по опыту, почти все кейсы будут опущены…(
lair
Не понимаю, каким образом.
Что значит "опущены"? В случае с эксепшном, если нет
catch
, разве ошибка не будет брошена в вызывающем коде?asvxyz Автор
Такой подход позволят узнать об ошибке «не отходя от кассы», как говориться. Более того, это банально лучше читается да и отладка такого кода, будет куда приятнее, без прыжков по коду, линейно.
lair
Позволяет узнать — да. Но как это стимулирует их обрабатывать?
А точно это лучше читается? Я вот предпочитаю видеть в первую очередь обработку основного сценария, а не ошибок.
asvxyz Автор
Обработка ошибок должна быть неотъемлемой частью сценария, иначе, как всегда, обработка будет пропущена, как и написание тестов, я полагаю…
lair
Почему обработка ошибок вида "у меня тут сеть недоступна" должна быть неотъемлемой частью бизнес-сценария?
Есть сильно больше одного случая, когда достаточно прервать выполнение, отрапортовать ошибку наверх, а супервизор сам разберется.
alexesDev
Только проблема в раскрытии имплементации бизнес логики... если планируется обрабатывать "у меня тут сеть недоступна" не как базовый Exception, то стоит делать обертку и это будет неотъемлемой частью логики. Убирать неявное - хорошая практика.
lair
Если планируется — то будет. А если не планируется, то не будет, и можно просто не писать лишнего кода.