Don't Let Errors Slow You Down: A Comprehensive Guide to JavaScript Error Handling
Errors can happen in any program. Their origin can be multiple, either from the code base of the program, from the third-party library it uses, or from external sources like doing I/O operations on a file, or do request on a database, …
Introduction
Errors can happen in any program. Their origin can be multiple, either from the code base of the program, from the third-party library it uses, or from external sources like doing I/O operations on a file, or do request on a database, …
Error handling is then important if you want to understand what happened in your program when it fails. Error handling also enables you to keep your application in a stable state. You can trigger some action when an error occurs to avoid breaking something.
For this reason, every programming language has its way of handling them.
In the first section, we will discuss what bad error handling is in Javascript, then we will see how we can make things better and show some good patterns, and in the last part, we will see how we can apply this to Typescript.
TL;DR
- Never ignore error
- Never override the original error
- Throw meaningful error
- Always handle promise, use async/await for more simplicity
- Always await promises as soon as they are launched
- Be careful when throwing an error to check if your error can be caught
- When defining a promise prefer using reject instead of throw
- Use promisify to deal with an asynchronous method using callback API
- Use custom error classes designed for your application
Errors in Javascript
Mistakes around error handling
There are several mistakes that can be done when dealing with errors. We will discuss here some of them and explain why they are bad practices. To do that we will go through several examples of code.
Ignoring the original error
The first thing to not do regarding errors would be to ignore them. In the example below, we are catching all errors that could be thrown by the method doSomeThings and then we pursue the execution like nothing happened.
Any errors happening here would be silent, so it would be hard to understand what could go wrong.
function doThings () {
try {
doSomeThings()
} catch (error) => {}
}
Overriding the original error
The second thing to not do regarding errors would be to override them. In the example below, we are catching all the errors like in the example above, but this time we log that an error has happened.
This might seem better compared to the previous example, but since we are not logging the original error, it would still be hard to understand what was the source of the error.
The original error would contain the stack trace that could help to understand which part of the code has failed.
function doThings () {
try {
doSomeThings()
} catch (error) => {
console.log('An error occurred')
}
}
Using a custom error, like in the code below, would still result in the same issue. Overriding the original error by a custom error would still make it lose the original message and stack trace.
The only improvement, in that case, would be that this custom error would have a stack trace that allows finding quickly that it was thrown here.
function doThings () {
try {
doSomeThings()
} catch (error) => {
throw new Error('An error occurred)
}
}
Forgetting handling Promises
When an error is rejected by a promise if that error isn’t caught anywhere, it will be thrown to the node.js process as an UnhandledPromiseRejection.
function doSomeThingsAsync() {
return new Promise((resolve, reject) => {
reject(new Error('test'))
})
}
function doThings () {
doSomeThingsAsync()
}
doThings()
When using Node.js 14 or the version below executing a code like this one, it would result to have the following output :
(node:268677) UnhandledPromiseRejectionWarning: Error: test
at /home/user/test.js:3:12
at new Promise (<anonymous>)
at doSomeThingsAsync (/home/user/test.js:2:10)
at doThings (/home/user/test.js:8:5)
at Object.<anonymous> (/home/user/test.js:13:1)
at Module._compile (internal/modules/cjs/loader.js:1085:14)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
at Module.load (internal/modules/cjs/loader.js:950:32)
at Function.Module._load (internal/modules/cjs/loader.js:790:12)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:75:12)
(Use `node --trace-warnings ...` to show where the warning was created)
(node:268677) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
(node:268677) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
That output has been improved since, so if you are using node.js 16 or versions above you should have this output:
/home/user/test.js:3
reject(new Error('test'))
^
Error: test
at /home/user/test.js:3:12
at new Promise (<anonymous>)
at doSomeThingsAsync (/home/user/test.js:2:10)
at doThings (/home/user/test.js:8:5)
at Object.<anonymous> (/home/user/test.js:13:1)
at Module._compile (node:internal/modules/cjs/loader:1105:14)
at Object.Module._extensions..js (node:internal/modules/cjs/loader:1159:10)
at Module.load (node:internal/modules/cjs/loader:981:32)
at Function.Module._load (node:internal/modules/cjs/loader:822:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12)
Since you don’t know exactly when your promise will reject, you don’t know when your program will stop its execution due to the unhandled rejection.
So if you want to catch this error you have to either use the .catch() method of the promise or use async/await with try/catch.
async function doThings () {
try {
await doSomeThingsAsync()
} catch(error) {
console.log("try/catch error : ", error) //<-- error caught here
}
}
async function doThings () {
doSomeThingsAsync().catch((error) => {
console.log("catch() error: ", error) //<-- error caught here
})
}
Now, what happens when we use throw instead of reject inside a promise?
function doSomeThingsAsync() {
return new Promise((resolve, reject) => {
throw new Error("test")
})
}
If you throw an error like this, you will see that the previous code will still work, you can either use the .catch() approach or the async/await with try/catch block.
The main difference between throwing or rejecting will be when throwing an error in another call stack.
If throwing in another new promise or when using setTimeout or setInterval the error will be directly thrown to the node.js process and it will not be possible to catch it.
function doSomeThingsAsync() {
return new Promise(async (resolve, reject) => {
await setTimeout(() => {
throw new Error('test')
}, 10)
})
}
async function doThings() {
try {
await doSomeThingsAsync()
} catch (error) {
console.log("try/catch error: ", error) //<-- never gets called
}
}
async function doThings () {
doSomeThingsAsync().catch((error) => {
console.log("catch() error: ", error) //<-- never gets called
})
}
Be careful if using async on the promise callback, that would result in having the callback running in another stack and it would make it impossible to catch the error.
function doSomeThingsAsync() {
return new Promise(async (resolve, reject) => {
throw new Error("test")
})
}
async function doThings() {
try {
await doSomeThingsAsync()
} catch (error) {
console.log("try/catch error: ", error) //<-- never gets called
}
}
doThings()
Resolving before throwing or rejecting
If your promise is somehow resolved before throwing or rejecting an error, the error will be completely ignored. You can try the following code:
function doSomeThingsAsync() {
return new Promise((resolve, reject) => {
resolve()
reject(new Error("test"))
throw new Error('test')
})
}
doSomeThingsAsync()
Launching promise and awaiting them later on
We saw that launching promises without handling them is a bad practice as the error would be unhandled and thrown into the node.js process.
A similar mistake would be to launch promises and await them later on. During the time a promise is launched and the moment it would be awaited an error could be thrown, and in this particular case if the promise was rejecting an error that error would be unhandled.
Let’s take the following example :
function doSomeFirstThingsAsync() {
return new Promise((resolve, reject) => {
reject(new Error('first promise error'))
})
}
function doSomeSecondThingsAsync() {
return new Promise((resolve, reject) => {
reject(new Error('second promise error'))
})
}
async function doThings () {
const firstPromise = doSomeFirstThingsAsync()
const secondPromise = doSomeSecondThingsAsync()
try {
await firstPromise
await secondPromise
} catch (error) {
console.error("error is caught", error)
}
}
doThings()
If you run this code, you will see that the first promise error is well caught and the second promise error is making the node.js process crash.
To understand what happened the execution is processed as the following:
- first, it will call
doSomeFirstThingsAsyncwhich put the promise in the event loop - then it will call
doSomeSecondThingsAsyncwhich put another promise in the event loop - then it will await the first promise
- the first promise will execute and reject an error
- the error will be caught in the catch block and it will be logged in the console
- then the execution of
doThingswill return - the process will then check for an unfinished job that remains in the event loop
- the second promise will execute and reject an error
- the process will catch the error and print the stack trace in the log before stopping
Forgetting to handle some case
When handling some call of method in a try/catch block, it’s very important to make sure that all the errors that could be thrown by this code are well handled as they should.
Let’s take a look at the following example:
async function doThings() {
try {
checkPermission()
await doSomeTransactionOnDatabase()
} catch (error) {
await rollbackChangeOnDatabase()
throw error
}
}
This method's purpose is to do some operations on a database. First, it will check that we have permission to do those changes and then it will do some transactions on the database. If there are any errors on the database transaction, it will roll back those changes.
The issue here is in the case checkPermission is throwing, the method rollbackChangeOnDatabase will be called even if doSomeTransactionOnDatabase has not been called. In that case, we can imagine that rollbackChangeOnDatabase will throw an error and the error that can be caught by this method call will not be a permission error, but a rollback error.
Here the solution would be just to move outside the try/catch the call to checkPermission so its error would be directly thrown.
async function doThings() {
checkPermission()
try {
await doSomeTransactionOnDatabase()
} catch (error) {
await rollbackChangeOnDatabase()
throw error
}
}
Dealing with Callback API
Before promises were introduced in node.js, the way to deal with asynchronous methods was using callbacks.
Some libraries are still using callbacks, but the norm is more using promises and async/await approach as it avoids callback hell.
If you want to use the “fs” node core module by default, its method are using the callback API. If you want to use promise you have to use “fs/promise” instead (and it would be more recommended).
Let’s take the following example:
import fs from "fs"
const path = "./file.json"
function readSomeFile(cb) {
try {
fs.readFile(path, (error, data) => {
if (error) {
throw error
}
cb(data)
})
} catch (error) {
console.error("an error has happened", error) // error is not caught
}
}
We have a method readSomeFile that will call the fs.readFile method to try to read a certain file.
When readFile completes either because there was an error or because it successfully read the file, it will call the arrow function callback we give to it in arguments.
This callback is taking two arguments, one that will receive the error if there was one, and one that will receive the data read if the file has been successfully read.
If we throw an error in the callback this error will be sent directly to the node process because the execution is in another call stack than our readSomeFile method, so the error will never be caught by the try/catch block here.
If we want to deal with the error here we will have to deal with the cb callback passed as an argument of readSomeFile. Basically, we will have to do something similar, with a callback that takes an argument for the error and one for the data.
function readSomeFile(cb) {
fs.readFile(path, (error, data) => {
if (error) {
cb(error)
}
cb(null, data)
})
}
That would be good if we want to keep the use of the callback API approach. But wouldn’t it be nicer if we could deal with promise instead?
We can promisify a method like this using a promise:
function readSomeFile() {
return new Promise((resolve, reject) => {
fs.readFile(path, (error, data) => {
if (error) {
reject(error)
}
resolve(data)
})
})
}
Doing it that way will allow us to use async/await and to be able to handle the error in a more simple way.
An even better way would be to use util.promisify to do exactly the same thing but in fewer lines of code.
async function readSomeFile() {
const readFile = util.promisify(fs.readFile)
return await readFile(path)
}
Good Error Handling
If we resume all the bad practices we have seen, we can consider good error handling will be to follow these rules:
- Never ignore error
- Never override the original error
- Throw meaningful error
- Always handle promise, use async/await for more simplicity
- Always await promises as soon as they are launched
- Be careful when throwing an error to check if your error can be caught
- When defining a promise prefer using reject instead of throw
- Use promisify to deal with an asynchronous method using callback API
To improve things even more, a good practice is to implement and use custom error classes designed for your application.
Creating custom Errors
Using customs errors can be interesting for several reasons.
It will help you to differentiate errors that come from your modules from errors that come from external libraries. Using specific error classes can help you to identify which one of your modules has sent the error.
To implement such an error class, you simply have to create a new class extending the Javascript Error class:
class CustomError extends Error {
constructor(message) {
super()
this.customProperty = "" // add any properties to the error here
}
}
You can create several error classes as templates of errors containing the data you might need to understand what happened. For example, you can define a custom error class that would contain a custom attribute for an HTTP status code, and you could use this error when you want to throw a HttpRequestError.
class HttpRequestError extends Error {
constructor(message, code = 500) {
super()
this.httpErrorCode = code
}
}
throw new HttpRequestError("The server is a teapot", 418)
With the inheritance, you can create a class of Error that extend generic error classes. It can help to have a specific class of error for different concerns.
class HttpRequestError extends Error {
constructor(message, code = 500) {
super()
this.httpErrorCode = code
}
}
class ServerIsATeapotHttpRequestError extends HttpRequestError {
constructor(message) {
const httpCode = 418
super(message, httpCode)
}
}
throw new ServerIsATeapotHttpRequestError("The server is a teapot")
If you group all your errors class into one module, it will allow you to reuse those errors class in multiple projects easily. You can even create abstract error classes to easily create templates for future error classes. This module would contain all the necessary information to handle errors in your application.
Using a different class of errors can help also when you want to handle a specific treatment for a specific type of error. For example, you could do something like that:
try {
doSomeThings()
} catch (error) {
if(error instanceof CustomError) {
// handle custom error
} else {
// handle other errors
}
}
Handling errors globally with scripts
In the general case, we want to catch all the errors at the top level of our apps so we can log them properly.
If you are doing a script, if you want to send the error to a specific logger, you will need to wrap your main method call around a try/catch block.
Depending on if you are doing CommonJS or ECMAScript Module code, the approach will be a little bit different.
With CommonJS, because it’s not possible to use async/await out of methods if the main method is asynchronous, you will write something like that:
async function mainMethod () {
await doThings()
}
mainMethod().catch(error) {
myLogger.error(error)
}
While with ECMAScript Module, you will write something like that:
try {
await doThings()
} catch (error) {
myLogger.error(error)
}
Of course, if your script doesn’t need to use a specific logger you can just let the Node.js process print the error in the console, and you wouldn’t require to catch anything a the top level.
Handling errors globally with an API framework
If you are doing an API with a framework like express or Fastify, those frameworks have a default error handler that will catch all the errors for you. Sometimes you’ll prefer to not let these default error handlers catch the error and send the error as an answer to the client.
If you want to handle that yourself, you will need to define an error handler middleware.
An error handler is the last middleware registered to be called after all other middleware has been executed.
In express, an error handler middleware is a middleware that takes four arguments (error, request, response, and next). You can define it like that:
app.use((err, req, res, next) => {
console.error(err.stack)
res.status(500).send('Something broke!')
})
You can refer to the official documentation for more information: https://expressjs.com/en/guide/error-handling.html
Errors in Typescript
How to handle basic Error
Now that we have seen how to handle properly errors in javascript, let’s talk about Typescript.
Since anything can be thrown in Javascript, Typescript can’t know in advance what could be type of errors caught. So by default, Typescript will give the type Unknown to the error when it’s caught.
function doSomeThing() {
try {
doSomeThingElse()
} catch (error) {
console.error(error.message) // 'error' type is 'unknown'
}
}
If you want to handle this you will have to do duck typing to ensure that the property “message” exists on the error.
Sadly it’s not possible to do something like this :
if(error?.message) { // Property 'clientMessage' does not exist on type '{}'
console.error(error.message)
}
We have to check if the property exists like this:
type ObjWithMessageProps = {
message: string
}
function hasMessageProps(obj: unknown): obj is ObjWithMessageProps {
return (
typeof obj === 'object' &&
obj !== null &&
'message' in obj &&
typeof (obj as Record<string, unknown>).message === 'string'
)
}
function doSomeThing() {
try {
doSomeThingElse()
} catch (error) {
if(hasMessageProps(error)) {
console.error(error.message)
}
}
}
Using custom error classes could help as we have seen because it would allow you to use instanceof to know the type of error.
Using custom Errors
We can define the error class in Typescript as we can do in Javascript. Typescript will just bring type for the attributes and method of the error class. So we can define a class like that:
export class CustomError extends Error {
name: string
message: string
constructor({name, message}) {
this.name = name
this.message = message
}
}
Conclusion
Being careful when handling errors will make your program more robust and makes you efficient in understanding their origin.
That’s why it’s important to identify the source of code that could throw to be sure you are doing it correctly. When using asynchronous methods even more attention should be given. Using async/await can help you to avoid the mistake of uncaught error, and don’t hesitate to promisify methods that use callback API.
You should make sure that the errors that you throw are expressive and give good information that will help to understand the source of the error.
Never manipulate the original error, because you might erase important information. If you want to add your custom data, you can just encapsulate it inside a custom-made error class.
Since Typescript will become Javascript code in the end, it’s really important to understand how to handle errors in Javascript.
Bibliography
- https://www.youtube.com/watch?v=hNaNjBBAdBo
- https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
- https://engineering.udacity.com/handling-errors-like-a-pro-in-typescript-d7a314ad4991
- https://www.smashingmagazine.com/2020/08/error-handling-nodejs-error-classes/
- https://catchjs.com/Docs/AsyncAwait