Как в современном мире обрабатывать ошибки в Javascript? -1


Если вы пришли сюда только ради ответа и вам не интересны рассуждения - листайте вниз :)

Как все начиналось

Для начала, давайте вспомним, а как вообще ловят ошибки в 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):

  1. lair
    /#24463482 / +1

    let [users, err] = await getUsers();
    if (err !== null) {
        switch (err) {
        case serviceUnavailable:
            // сервис недоступен
        case notFoundError:
            // пользователи не найдены
        default:
            // действие при неизвестной ошибке
        }
    }

    мы гарантированно поймаем все ошибки

    … и что гарантирует, что возвращенная ошибка действительно будет обработана, а не будет так, что вызывающий код просто ее отбросит и вернет users?

    • halfcupgreentea
      /#24463532 / +1

      Тут может помочь TS: пока не будет проверен тип err, тип users будет Users | null Например

    • asvxyz
      /#24463582

      Действительно, ничто не может заставить разработчика обязательно обрабатывать все ошибки. Более того, такой способ позволяет не извлекать ошибку вообще. Но, такой код стимулирует разработчика озаботиться обработкой ошибки. В случае с try…catch, по опыту, почти все кейсы будут опущены…(

      • lair
        /#24463584 / +1

        Но, такой код стимулирует разработчика озаботиться обработкой ошибки.

        Не понимаю, каким образом.


        В случае с try…catch, по опыту, почти все кейсы будут опущены…

        Что значит "опущены"? В случае с эксепшном, если нет catch, разве ошибка не будет брошена в вызывающем коде?

        • asvxyz
          /#24463616 / -1

          Такой подход позволят узнать об ошибке «не отходя от кассы», как говориться. Более того, это банально лучше читается да и отладка такого кода, будет куда приятнее, без прыжков по коду, линейно.

          • lair
            /#24463728 / +3

            Такой подход позволят узнать об ошибке «не отходя от кассы», как говориться.

            Позволяет узнать — да. Но как это стимулирует их обрабатывать?


            Более того, это банально лучше читается

            А точно это лучше читается? Я вот предпочитаю видеть в первую очередь обработку основного сценария, а не ошибок.

            • asvxyz
              /#24463872 / -1

              Обработка ошибок должна быть неотъемлемой частью сценария, иначе, как всегда, обработка будет пропущена, как и написание тестов, я полагаю…

              • lair
                /#24463882 / +4

                Обработка ошибок должна быть неотъемлемой частью сценария

                Почему обработка ошибок вида "у меня тут сеть недоступна" должна быть неотъемлемой частью бизнес-сценария?


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

                • alexesDev
                  /#24465458 / -1

                  Только проблема в раскрытии имплементации бизнес логики... если планируется обрабатывать "у меня тут сеть недоступна" не как базовый Exception, то стоит делать обертку и это будет неотъемлемой частью логики. Убирать неявное - хорошая практика.

                  • lair
                    /#24465944

                    Если планируется — то будет. А если не планируется, то не будет, и можно просто не писать лишнего кода.

  2. dpereverza
    /#24463514 / +5

    Вариант с Go'шной обработкой ошибок, заставляет нас проверить на ошибки результат. Но с той же легкостью мы можем этого и не делать)

    Ленивые разработчики будут писать что-то типа
    [chats] = await httpGET('https://api.example.com/chats');

    Но на CodeReview такие косяки будет заметнее, и это хорошо.
    А вот что плохо, так это загрязнение кода от этих постоянных проверок.

    Есть ФП подход к обработке ошибок, он сложнее, но если привыкнуть то гуд.
    https://habr.com/ru/post/457098/

    • asvxyz
      /#24463660 / -2

      Да, такой подход позволяет опускать ошибки (как и в го, кстати), но позволяет меньше отвлекаться на этапе прототипирования.

      По поводу «плохо» - если может возникнуть ошибка, то она возникнет) Это первое. Если мы не хотим проверять ошибки(загрязнять код(c)), то можно как в случае с try…catch замести все под коврик и просто вывести пользователю “sorry…”

  3. SergeiMinaev
    /#24463592

    Для начала, давайте вспомним, а как вообще ловят ошибки в js, будь то браузер или сервер. В js есть конструкция try...catch.

    Несмотря на то, что даже в MDN фигурирует именно слово "ошибки", мне кажется более уместным всё-таки называть это исключениями. В англ. оригинале как раз используется слово "exception".

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

    • asvxyz
      /#24463626 / -2

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

  4. monochromer
    /#24463668 / +2

    Позанудствую.

    if (users.length === 0) {
      return Promise.resolve([null, notFoundError]);
    }

    Нулевая длина списка вряд ли должна описываться ошибкой notFoundError. Ведь коллекция `users` есть. Если бы пользователь пошёл по пути `/users/1/`, а пользователя c `id = 1` нет, то тогда можно отдать notFoundError.

    Из async-функции разве не достаточно возвращать просто массив `[data, error]` без оборачивания в Promise.resolve ?

    • asvxyz
      /#24463844

      Так написано для большей наглядности. Да и мне так больше нравится (субъективно).

      Что касается ‘not found’ , действительно, это притянутый за уши пример, тут может быть любая другая ошибка.

  5. return
    /#24463796 / +5

    Далеко не всегда нужно обрабатывать ошибки и очень часто ошибка, брошенная через throw вполне себе может привести к 500 ошибке и записана куда-нибудь в лог и это будет правильно.

    Все удобство в try..catch в том, чтобы отловить только те ошибки, на которые ты должен как-то специфически отреагировать. А остальные - ну а как ты их нормально обработаешь? Пусть себе ловит какой-то общий обработчик, который запишет в лог, а юзеру скажет сорян. Увидел в логе необработанную ошибку, понял, что такой ситуации можно избежать — делаешь catch и именно ее и ловишь, как-то так ????‍♂️

    • asvxyz
      /#24463858 / -1

      Для описанного вами случая можно использовать “switch default”

      • return
        /#24463866 / +4

        А зачем, когда я просто не хочу смотреть на ошибку ни на одном из уровней, которых может быть дофига

        • asvxyz
          /#24463904 / -2

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

          • return
            /#24463936 / +3

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

            Все это надо держать в балансе, заставлять разработчика проверять ошибки всегда — неправильно

          • lair
            /#24464604 / +3

            Такой способ позволяет обработать разумные ошибки, а не просто написать в лог.

            … а обычный catch не позволяет?

            • asvxyz
              /#24465786

              Позволяет!

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

              • lair
                /#24465942 / +1

                Но идея такого подхода, фактически, не заменить, но дополнить стандартный подход

                Когда в системе два подхода к обработке ошибок, программисты начинают их путать.

                • asvxyz
                  /#24465964 / -1

                  Я выше приводил примеры такого как ошибки обрабатываются в случает XMLHttpRequest и readFile, в первом случае - подписка на событие ошибки, второй - ошибка передаётся первым аргументом в колбеке. Вообще забавна такая реакция сообщества, такое ощущение, что никто не знает про вышеописанные способы/приёмы. Вариант который предлагаю я, по сути, синтаксический сахар для ErrorFirstCallback. С трудом верится, что люди не знают такие вещи, видимо, такие люди просто более активны в комментариях…

                  • lair
                    /#24465972

                    Я выше приводил примеры такого как ошибки обрабатываются в случает XMLHttpRequest и readFile, в первом случае — подписка на событие ошибки, второй — ошибка передаётся первым аргументом в колбеке.

                    Это называется "легаси". И это как раз и неудобно, что для обработки ошибок используются разные подходы.

                    • asvxyz
                      /#24465994

                      Подход у XMLHttpRequest - показывает, что жизнь может быть чуть сложнее. С readFile, действительно, сейчас есть возможность использовать вариант с промисом. Но старый вариант позволял выполнить определенный код не теряя "контекст".

                      • lair
                        /#24466024

                        Подход у XMLHttpRequest — показывает, что жизнь может быть чуть сложнее.

                        Так это неудобно же. Наличие нескольких разных способов обработать ошибку — неудобно (потому что лишает код консистентности).

                      • asvxyz
                        /#24466056

                        И да, и нет. XMLHttpRequest имеет много разных событий: error, abort, timeout... Это как раз пример того, что ошибок может быть много и разных, и специфика этих ошибок в том, что их нужно по разному обрабатывать. Нас же не смущает, то, что мы используем колбеки для подписки на события. Timeout, abort, error - тоже события. С другой стороны, с "религиозной" точки зрения - "Это другое!".

                      • lair
                        /#24466078 / +1

                        Это как раз пример того, что ошибок может быть много и разных, и специфика этих ошибок в том, что их нужно по разному обрабатывать.

                        Нет, это пример того, что бывают события, которые отражают ошибку.


                        специфика этих ошибок в том, что их нужно по разному обрабатывать

                        Ну так ошибки и в catch можно по-разному обрабатывать.


                        Нас же не смущает, то, что мы используем колбеки для подписки на события.

                        Если эти события нужны только для поддержания асинхронии — смущает.


                        Надо разделять ошибку как результат процесса (тогда ошибок больше возникнуть не может, можно ее вернуть из метода, как в вашем примере, или бросить как в try-catch), или ошибку как событие в процессе (они могут возникнуть еще потом, процесс не остановился, тогда ее нельзя вернуть, не годится ни try-catch, ни ваш подход).

                      • asvxyz
                        /#24466134 / -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
                        /#24466140 / +1

                        В вашем первом примере общая обработка для двух источников ошибки — это преимущество. А если надо разделить обработку — никто не мешает написать два разных блока try…catch


                        Что же до ReferenceError — да, не вполне удобно, но способ обхода общеизвестен. Всего-то надо объявить переменную блоком выше.

                      • asvxyz
                        /#24466146 / -2

                        То есть, все таки, лишний код можно писать, но только тогда, когда его ты сам благословил?

                      • mayorovp
                        /#24466164

                        В каком смысле "благословил"?

                      • asvxyz
                        /#24466182

                        В комментах были возмущения на тему того, что это лишний код, лишние проверки... Хоть ты это не писал, просто у меня накопилось) не наезд, ни в коем случае)

                        Благословил - я имел ввиду, что когда другие пишут 'лишний' код, это очень плохо, нужно минусовать. Если я пишу - это правильно, я знаю как лучше.

                        Вот так, грубо говоря.

                      • mayorovp
                        /#24466196

                        Повторюсь:


                        общая обработка для двух источников ошибки — это преимущество

                        Согласно моему опыту, гораздо чаще приходится обрабатывать ошибки совместно, а не раздельно. Потому и оптимизируется именно совместная обработка ошибок.

                      • asvxyz
                        /#24466208 / -2

                        Чаще / реже, не значит, что нужно использовать подход обозначенный в статье, так же как и не значит, что не нужно использовать общий подход. Это просто еще один способ (по моему мнению более прогрессивный), не более того. Ну и "субъективно" для меня, читаемость такого кода лучше

                      • lair
                        /#24466144

                        Да, но тут ты на уровне интерфейса (названия событий) понимаешь какие могут быть ошибки.

                        Нет. Я понимаю, какие могут быть события. А все ошибки — это все так же error. Вы можете сказать, какие ошибки могут там быть?


                        Да, можно использовать только try...catch, но...

                        … а что вам мешает использовать больше одного try-catch?

                      • asvxyz
                        /#24466152 / -2

                        читай мой коммент выше

                      • lair
                        /#24466162

                        Не отвечает на мои вопросы.

                      • asvxyz
                        /#24466200 / -2

                        Разная специфика ошибок, требующая по-разному реагировать.

                        Кажется, что я столкнулся с 'религиозной' догмой...

                      • mayorovp
                        /#24466226

                        На религиозную догму больше похож как раз ваш вариант.

                      • asvxyz
                        /#24466238

                        Серьезно? Я говорю, что можно применять разные подходы в зависимости от ситуации. Я не ограничиваюсь лишь своим подходом, меня не пугает ни try...catch, не onError, ни Error-First-Callback. Пока мне не слили рейтинг с кармой я не минусовал людей с другой точкой зрения, а общался. Но в тоже время, меня решили заминусовать. Так где же у меня догма?

                      • lair
                        /#24466250

                        Разная специфика ошибок, требующая по-разному реагировать.

                        Так как раз это прекрасно покрывается разными try-catch и дифференцированной обработкой ошибок в них. В чем проблема?

                      • asvxyz
                        /#24466288

                        Как я писал выше, способ описанный в статье, позволяет обрабатывать ошибки по мере их поступления (возможного). Что касается catch, действительно, он ловит все ошибки и мы спокойно их все разгребем. Проблема лишь в том, что мы все это делаем в одной куче.

                      • lair
                        /#24466312

                        Как я писал выше, способ описанный в статье, позволяет обрабатывать ошибки по мере их поступления (возможного).

                        А я думал, он их обрабатывает после возврата из метода?...


                        Проблема лишь в том, что мы все это делаем в одной куче.

                        Ну так не делайте в одной куче, делайте столько try-catch, сколько вам надо.

                      • asvxyz
                        /#24466326

                        Ну вот мое мнение, как раз в том и состоит, что по отдельности обрабатывать ошибки удобнее (по моему мнению, два try-catch в одном блоке уже перебор ) способом из статьи, если нужно все вместе - catch

                      • lair
                        /#24466472

                        по моему мнению, два try-catch в одном блоке уже перебор

                        Но почему?


                        А еще понимаете ли в чем дело, вызываемый код не знает, как его будут использовать. И как ему возвращать ошибки?..

                      • asvxyz
                        /#24466626 / -2

                        Естественно, что не знает. Точно также, как и код возвращающий объект типа.

                        type Result = {
                            status: boolean;
                            message: string;
                        }

                      • lair
                        /#24466628

                        Так и как же вы предлагаете вызываемому коду возвращать ошибки, учитывая, что у вас две разных парадгмы в зависимости от того, как код вызывается?

                      • asvxyz
                        /#24466334 / -2

                        Кстати, если попытаться отвлечься от войны, можно увидеть, что наш разговор превратился в спор похожий на ситуацию "табуляция против пробела"... =)

                      • lair
                        /#24466488

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

                      • asvxyz
                        /#24466608 / -2

                        Как жаль, что я уже не могу поставить вам плюс.

                      • asvxyz
                        /#24466636 / -2

                        @lair, друг, извини, что я так сильно тебя обидел, и что из-за этого тебе пришлось лепить минусы на все мои сообщения.

  6. Deka87
    /#24464126 / +3

    В заголовке указан JavaScript, а в примерах TypeScript. Наверное, не стоит подразумевать, что все JavaScript разработчики пишут на последнем. Тем более, что эту статью могут найти через много лет, когда TS может быть уже не таким популярным, и это введёт в заблуждение.

    • Ilusha
      /#24464540 / -1

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

      А через много лет кто-нибудь напишет новую статью.

      • Deka87
        /#24464572 / +5

        Вероятно также думали разработчики, которые писали на jQuery в своё время (не смотря на всю разницу между jQuery и TS). Предлагаю все таки придерживаться каких-то правил и указывать TS, если речь идёт о TS, и JS, если речь идёт о чистом JS. Иначе многие начинающие разработчики могут не понять неожиданный синтакс.