Best Practice for Error Handling in Modern JavaScript

When you write code, it is important to consider error situations. Error handling is an integral part of working on a web application. We’ll take a look at some guidelines for handling errors in JavaScript. In order not to waste your time in vain, we immediately explain that what is described in the article may not be new to experienced coders. If you think of yourself as such – feel free to skip this material, everyone else is invited under cat.


Extending the Error class

It is often helpful to provide a detailed description of the error within a handler. And by that I mean more than just clear error messages. I mean extending the class Error… Expanding the class Error, you can configure properties useful for debugging name and messageas well as write custom getters, setters and other methods:

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

This approach can smooth debugging when multiple scripts in your code throw the same error for the same reasons and need additional context. It’s a bit tricky to go back and figure out the exact cause without a utility (depending on the error, of course).

Let’s look at the situation when the expansion Error benefits. Let’s say you have a function that takes a list of solver functions. It takes an argument, walks through the list of solvers, and passes the argument to each function. If the function returns any result, the pass stops and this result is returned by the function:

// 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
  }
}

Imagine that you are writing a page where the user is asked to enter their year of birth in order to assign them to some group:

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}`)
})

When the user clicks OK, his age is assigned userInput and passed as an argument to the function from 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}`)
})

At the end of the work, it starts window.alertto show the user his group:

The code works fine. But what if the user is not viewing the page in Chrome? Then the line resolvers.push(someResolverFn) does not work. Below we see an unpleasant result:

We can prevent unhandled errors by dropping the usual Error, or we can use a more suitable 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
  }
}

This makes the error much less likely to get to the user. The message forces the developer to fix the situation:

Debugging is greatly simplified when several functions work according to this strategy and some property is attached to them. Now all this can be written differently and such a protective mechanism is better:

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}`)
})

Using TypeError

We often work with Error, but when there is a more appropriate built-in error, it’s useful not to neglect it:

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
    }
  })

Testing

Thanks to inheritance Error testing becomes more reliable. Such errors can be used when writing asserts:

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)
})

It’s important not to overdo it.

It is tempting to just create many custom bugs for every possible situation. Especially when you are just starting out with JavaScript. If the application is small or medium in size, you can get into this situation:

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)
  }
}

Then you rethink the approach and try to understand if you really need such error handling. In most cases, it is sufficient to clearly report the error. Assert only brings maximum benefit when context needs to be added. For example, a token to retry a request on timeout:

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)

Remember, error handling saves you money and time.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *