Best Practices to Control Your Errors
September 12th, 2020
It is important to keep in consideration the different scenarios that errors could occur while writing your code. They play an integral part to your web application because you can avoid deadly scenarios (like your application crashing completely) that would otherwise not have been avoided if left unhandled.
This post will go over some best practices on controlling errors and handling them in JavaScript.
It's often useful to provide a more descriptive error inside your error handlers. And by this I don't just mean writing your error message clearer. What I'm referring to is to extend the Error
class.
By doing this you can customize the name
and message
property that will be useful for debugging as well as attach custom getters, setters, and methods that need it:
class BadParametersError extends Error {
name = 'BadParametersError'
constructor(message) {
super(message)
}
get recommendation() {
return this._recommendation
}
set recommendation(recommendation) {
this._recommendation = recommendation
}
}
This can provide a smoother debugging experience especially if you have multiple blocks of code that throw similarly. When multiple scenarios in your code can throw for the same reasons and need some additional context it can become a little difficult to go back and find out exactly why it was thrown without a utility--depending on the kind of error it is of course.
Lets go over a scenario where this becomes useful.
Lets say you have a function that takes a list of resolver functions. The function takes an argument, and when it runs it will loop through the resolvers and pass the argument to each function. If a function returns a result then it stops the loop and returns the result:
// 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
}
}
Now lets pretend we are building a page that prompts a user to enter the year they were born before assigning them to some group based on their age:
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}`)
})
This code prompts the user and when they click OK, their age gets assigned to userInput
and gets sent as an argument to the function produced by composeResolvers
:
And when it finishes it runs window.alert
to show the user the computed group:
This is working fine but what if the user wasn't using the chrome browser? That means this line wouldn't run:
resolvers.push(someResolverFn)
This generates an embarassing unexpected result:
We can prevent unhandled errors like these by throwing a normal Error
or we can use the more specific 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 way this error has a much lower chance to getting to the user and makes the developer fix the mistake:
If multiple functions use this strategy and were attached property like recommended
for example, the debugging experience becomes much easier.
Now it can be written to something like this which is a better defense mechanisms:
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
We often handle errors by throwing Error
. But when built-in JavaScript errors are available to narrow the scenario when the opportunity arrives, its useful to just leverage them:
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
}
})
By leveraging derived Error
s testing becomes more robust as you're able to directly use them to make your assertions:
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)
})
Knowing how useful it is it also becomes tempting to just create a bunch of custom errors for every possible scenario you can think of, especially when you're just beginning to learn JavaScript. So if you have a small to medium sized app and end up in a situation like this:
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 might rethink this approach and determine if you really need to do this or not. Most of the time its enough to provide a clearer error message
. Making assertions about custom errors only provide maximum benefit when you need to add some extra context to it like a token to retry on a timed out request, for example:
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)
Controlling your errors provides control over your app. It also saves you time and money. This concludes the end of this post! I hope you found this to be valuable and look out for more in the future!