Лучшая практика обработки ошибок в современном JavaScript

Автор оригинала: Christopher Tran
  • Перевод
  • Tutorial

Когда вы пишете код, важно учитывать ситуации, приводящие к ошибкам. Обработка ошибок — это неотъемлемая часть работы над веб-приложением. Мы посмотрим на некоторые рекомендации по обработке ошибок в JavaScript. Чтобы не тратить ваше время зря, сразу поясняем, что описанное в статье может быть не в новинку многопытным кодерам. Если вы себя таким считаете — смело пропускайте этот материал, всех остальных приглашаем под кат.



Расширяем класс Error


Часто бывает полезно предоставить детальное описание ошибки внутри обработчика. И под этим я подразумеваю не только четкое сообщения об ошибке. Я имею в виду расширение класса Error. Расширив класс Error, вы можете настроить полезные при отладке свойства name и message, а также написать пользовательские геттеры, сеттеры и другие методы:

class BadParametersError extends Error {
  name = 'BadParametersError'
  constructor(message) {
    super(message)
  }
  get recommendation() {
    return this._recommendation
  }
  set recommendation(recommendation) {
    this._recommendation = recommendation
  }
}

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

Посмотрим на ситуацию, когда расширение Error приносит пользу. Допустим, у вас есть функция, принимающая список функций решателя. Она принимает аргумент, проходит по списку решателей и передает аргумент в каждую функцию. Если функция возвращает какой-то результат, проход останавливается и этот результат возвращается функцией:

// Takes a list of resolvers, composes them and returns a func that calls
// each resolvers on the provided args.
function composeResolvers(...resolvers) {
  return (args) => {
    let result
    for (let index = 0; index < resolvers.length; index++) {
      const resolve = resolvers[index]
      result = resolve(args)
      if (result) {
        break // Abort the loop since we now found a value
      }
    }
    return result
  }
}

Представьте, что вы пишете страницу, где пользователю предлагается ввести год рождения, чтобы определить его в какую-то группу:

import composeResolvers from '../composeResolvers'
const resolvers = []
const someResolverFn = (userInput) => {
  if (userInput > 2002) {
    return 'NewKidsOnTheBlock'
  }
  return 'OldEnoughToVote'
}
// Pretending our code is only stable/supported by certain browsers
if (/chrome/i.test(navigator.userAgent)) {
  resolvers.push(someResolverFn)
}
const resolve = composeResolvers(...resolvers)
window.addEventListener('load', () => {
  const userInput = window.prompt('What year was your computer created?')
  const result = resolve(userInput)
  window.alert(`We recommend that you register for the group: ${result}`)
})

Когда пользователь нажимает OK, его возраст присваивается userInput и передается в качестве аргумента функции из composeResolvers:

import composeResolvers from '../composeResolvers'
const resolvers = []
const someResolverFn = (userInput) => {
  if (userInput > 2002) {
    return 'NewKidsOnTheBlock'
  }
  return 'OldEnoughToVote'
}
// Pretending our code is only stable/supported by certain browsers
if (/chrome/i.test(navigator.userAgent)) {
  resolvers.push(someResolverFn)
}
const resolve = composeResolvers(...resolvers)
window.addEventListener('load', () => {
  const userInput = window.prompt('What year was your computer created?')
  const result = resolve(userInput)
  window.alert(`We recommend that you register for the group: ${result}`)
})



По окончании работы запускается window.alert, чтобы показать пользователю его группу:



Код работает нормально. Но что, если пользователь смотрит страницу не в Chrome? Тогда строка resolvers.push(someResolverFn) не работает. Ниже мы видим неприятный результат:



Мы можем предупредить необработанные ошибки, бросив обычную Error, или можем использовать более подходящую BadParametersError:

// Takes a list of resolvers, composes them and returns a func that calls
// each resolvers on the provided args.
function composeResolvers(...resolvers) {
  if (!resolvers.length) {
    const err = new BadParametersError(
      'Need at least one function to compose resolvers',
    )
    err.recommendation =
      'Provide a function that takes one argument and returns a value'
    throw err
  }
  return (args) => {
    let result
    for (let index = 0; index < resolvers.length; index++) {
      const resolve = resolvers[index]
      result = resolve(args)
      if (result) {
        break // Abort the loop since we now found a value
      }
    }
    return result
  }
}

Так у ошибки гораздо меньше шансов попасть к пользователю. Сообщение заставляет разработчика исправить ситуацию:



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

const resolve = composeResolvers(...resolvers)
window.addEventListener('load', () => {
  const userInput = window.prompt('What year was your computer brought to you?')
  let result
  try {
    result = resolve(userInput)
  } catch (error) {
    if (error instanceof BadParametersError) {
      console.error(
        `[Error] ${error.message}. Here's a recommendation: ${error.recommendation}`,
      )
      console.log(error.recommendation)
    } else {
      // Do some fallback logic
      return window.alert(
        'We are sorry, there was a technical problem. Please come back later',
      )
    }
  }
  window.alert(`We recommend that you register for the group: ${result}`)
})

Применение TypeError


Мы часто работаем с Error, но когда есть более подходящая встроенная ошибка, полезно не пренебрегать ей:

async function fetchDogs(id) {
  let result
  if (typeof id === 'string') {
    result = await api.fetchDogs(id)
  } else if (typeof id === 'array') {
    result = await Promise.all(id.map((str) => api.fetchDogs(id)))
  } else {
    throw new TypeError(
      'callSomeApi only accepts a string or an array of strings',
    )
  }
  return result
}
const params = { id: 'doggie123' }
let dogs
fetchDogs(params)
  .then((dogs) => {
    dogs = dogs
  })
  .catch((err) => {
    if (err instanceof TypeError) {
      dogs = Promise.resolve(fetchDogs(params.id))
    } else {
      throw err
    }
  })


Тестирование


Благодаря наследованию Error тестирование становится надежнее. Такие ошибки можно использовать при написании ассертов:

import { expect } from 'chai'
import chaiAsPromised from 'chai-as-promised'
import fetchCats from '../fetchCats'
chai.use(chaiAsPromised)
it('should only take in arrays', () => {
  expect(fetchCats('abc123')).to.eventually.rejectWith(TypeError)
})


Важно не перестараться


Возникает соблазн просто создать много пользовательских ошибок для каждой возможной ситуации. Особенно когда вы только начинаете изучать JavaScript. Если приложение маленькое или среднее по размеру приложение, можно попасть в такую ситуацию:

class AbortExecuteError extends Error {
  name = 'AbortExecuteError'
  constructor(message) {
    super(message)
  }
}
class BadParameters extends Error {
  name = 'BadParameters'
  constructor(message) {
    super(message)
  }
}
class TimedOutError extends Error {
  name = 'TimedOutError'
  constructor(message) {
    super(message)
  }
}
class ArrayTooLongError extends Error {
  name = 'ArrayTooLongError'
  constructor(message) {
    super(message)
  }
}
class UsernameError extends Error {
  name = 'UsernameError'
  constructor(message) {
    super(message)
  }
}

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

class TimedOutError extends Error {
  name = 'TimedOutError'
  retried = 0
  constructor(message) {
    super(message)
  }
  set retry(callback) {
    this._retry = callback
  }
  retry(...args) {
    this.retried++
    return this._retry(...args)
  }
}
class ConnectToRoomTimedOutError extends TimedOutError {
  name = 'ConnectToRoomTimedOutError'
  constructor(message) {
    super(message)
  }
  get token() {
    return this._token
  }
  set token(token) {
    this._token = token
  }
}
let timeoutRef
async function connect(token) {
  if (timeoutRef) clearTimeout(timeoutRef)
    timeoutRef = setTimeout(() => {
      const err = new ConnectToRoomTimedOutError(
        'Did not receive a response from the server',
      )
      err.retry = connect
      err.token = token
      throw err
    }, 10000)
    const room = await api.join(token)
    clearTimeout(timeoutRef)
    return room
  }
}
const joinRoom = () => getToken().then((token) => connect(token))
async function start() {
  try {
    let room = await joinRoom()
    return room
  } catch (err) {
    if (err instanceof ConnectToRoomTimedOutError) {
      try {
        // Lets retry one more time
        room = await err.retry(err.token)
        return room
      } catch (innerErr) {
        throw innerError
      }
    }
    throw err
  }
}
start()
  .then((room) => {
    console.log(`Received room, oh yea!`, room)
  })
  .catch(console.error)

Помните, что обработка ошибок экономит ваши деньги и время.

image

Получить востребованную профессию с нуля или Level Up по навыкам и зарплате, можно, пройдя онлайн-курсы SkillFactory:



SkillFactory
Школа Computer Science. Скидка 10% по коду HABR

Комментарии 5

    0
    Спасибо.
    После абзаца «Когда пользователь нажимает OK, его возраст присваивается userInput и передается в качестве аргумента функции из composeResolvers:» идёт почему-то повторение предыдущего кода. Видимо предполагался всё же первый вариант composeResolvers
      +1
      async function fetchDogs(id) {
        let result
        if (typeof id === 'string') {
          result = await api.fetchDogs(id)
        } else if (typeof id === 'array') {
          result = await Promise.all(id.map((str) => api.fetchDogs(id)))
        } else {
          throw new TypeError(
            'callSomeApi only accepts a string or an array of strings',
          )
        }
        return result
      }
      const params = { id: 'doggie123' }
      let dogs
      fetchDogs(params)
        .then((dogs) => {
          dogs = dogs
        })
        .catch((err) => {
          if (err instanceof TypeError) {
            dogs = Promise.resolve(fetchDogs(params.id))
          } else {
            throw err
          }
        })


      Как я ненавижу функции которые могут принимать различные типы параметров. А тут в придачу оно еще и возвращает разные структуры. Тестировать такое просто одно удовольствие. И баги ловить когда этот код уже заюзали в десятках мест, и рефакторить… Мы такое на ревью не пропускаем. Ну сделай ты общую функцию которая работает с массивами, и оберни ее аккуратно функцией которая принимает один параметр. И чище, и без магии, и тестов меньше писать нужно.

      Ну и на бэкенде далеко с этим не уедешь. Почти всегда нужно знать root cause, поэтому либо error chaining, либо копируем нужные данные с оригинальной ошибки. Ну и ошибки хттп клиентов ловим как можно раньше и оборачиваем в доменные ошибки, и обрабатываем их уже как можно позже, по возможности (как там у жавистов, кидай рано, обрабатывай поздно?).

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

      Ну а в принципе работа с ошибками без тайп скрипта, то еще удовольствие… мы как те йожики с кактусом =)
        +2
        Ну а в принципе работа с ошибками без тайп скрипта, то еще удовольствие

        При всей моей любви к TS, чем вам так сильно помогают } catch(error) { и .catch(error => которые всегда any? :-)

        +1
        class ArrayTooLongError extends Error {
          name = 'ArrayTooLongError'
          constructor(message) {
            super(message)
          }
        }
        
        // focus mocus
        
        class ArrayTooLongError extends Error {
          name = 'ArrayTooLongError'
        }

        class BadParametersError extends Error {
          name = 'BadParametersError'
          constructor(message) {
            super(message)
          }
          get recommendation() {
            return this._recommendation
          }
          set recommendation(recommendation) {
            this._recommendation = recommendation
          }
        }
        
        // focus mocus
        
        class BadParametersError extends Error {
          name = 'BadParametersError'
          _recommendation = null
        }

        Дайте угадаю, до этого вы писали на Java | C#?

          +1

          Есть неочевидный подводный камень при наследовании от Error в Typescript.


          class MyError extends Error {}
          const myError = new MyError;
          console.log(myError instanceof MyError);

          С target=es5 этот кода напечатает false. https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work


          Таким образом, идет лесом поддержка IE11 (так ей и надо) и тесты с Jest (который никак не научится нормально es modules поддерживать https://github.com/facebook/jest/issues/4842. кстати, будет ли работать, если запускать Jest с опциями target=es2017, module=commonjs?).


          В общем, будьте внимательны, тестируйте свой код :)

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

          Самое читаемое