This is the first part of a series of articles where I’ll share tips to write cleaner and more effective node.js code.
1. Use async/await
So there are 3 ways of writing asynchronous code in Javascript: callbacks, promises and async/await.
(If you haven’t escaped callback hell yet, I encourage you to check out another dev.to article: How to Escape Callback Hell with JavaScipt Promises by @amberjones)
Async/await allows us to build asynchronous non-blocking code with a cleaner and more readable syntax than promises 👍.
Let’s see an example, the following code executes myFunction()
, returns the result and handles any errors that may be thrown by the function:
// Promises
myFunction()
.then(data => {
doStuff(data);
})
.catch(err => {
handle(err);
});
// async/await
try {
const data = await myFunction();
doStuff(data);
}
catch (err) {
handle(err);
}
Isn’t it cleaner and easier to read with async/await
?
A few of extra tips regarding async/await:
- Any function that returns a Promise can be awaited.
- The
await
keyword can only be used within async functions. - You can execute async functions in parallel using
await Promise.all([asyncFunction1, asyncFunction2])
.
2. Avoid await in loops
Since async/await is so clean and readable we may be tempted to do something like this:
const productsToUpdate = await productModel.find({ outdated: true });
for (const key in productsToUpdate) {
const product = productsToUpdate[key];
product.outdated = false;
await product.save();
}
The above code retrieves a list of products using find
and then iterates through them and updates them one by one. It will probably work, but we should be able to do better 🤔. Consider the following alternatives:
Option A: Write a single query
We could easily write a query that finds the products and updates them all in one, thus delegating the responsibility to the database and reducing N operations to just 1. Here’s how:
await productModel.update({ outdated: true }, {
$set: {
outdated: false
}
});
Option B: Promise.all
To be clear, in this example the Option A would definitely be the way to go, but in case the async operations cannot be merged into one (maybe they’re not database operations, but requests to an external REST API instead), you should consider running all the operations in parallel using Promise.all
:
const firstOperation = myAsyncFunction();
const secondOperation = myAsyncFunction2('test');
const thirdOperation = myAsyncFunction3(5);
await Promise.all([ firstOperation, secondOperation, thirdOperation ]);
This approach will execute all the async functions and wait until all of them have resolved. It only works if the operations have no dependencies with one another.
3. Use async fs modules
Node’s fs
module allows us to interact with the file system. Every operation in the fs
module contains a synchronous and an asynchronous option.
Here’s an example of async and sync code to read a file 👇
// Async
fs.readFile(path, (err, data) => {
if (err)
throw err;
callback(data);
});
// Sync
return fs.readFileSync(path);
The synchronous option (usually ends with Sync
, like readFileSync
) looks cleaner, because it doesn’t require a callback, but it could actually harm your application performance. Why? Because Sync operations are blocking, so while the app is reading a file synchronously its blocking the execution of any other code.
However, it will be nice to find a way we could use the fs
module asynchronously and avoid callbacks too, right? Checkout the next tip to find out how.
4. Convert callbacks to promises with util.promisify
promisify
is a function from the node.js util
module. It takes a function that follows the standard callback structure and transforms it to a promise. This also allows to use await
on callback-style functions.
Let’s see an example. The function readFile
and access
, from node’s fs
module, both follow the callback-style structure, so we’ll promisify them to use them in an async function with await
.
Here’s the callback version:
const fs = require('fs');
const readFile = (path, callback) => {
// Check if the path exists.
fs.stat(path, (err, stats) => {
if (err)
throw err;
// Check if the path belongs to a file.
if (!stats.isFile())
throw new Error('The path does not belong to a file');
// Read file.
fs.readFile(path, (err, data) => {
if (err)
throw err;
callback(data);
});
});
}
And here’s the “promisified” + async version 👌:
const util = require('util');
const fs = require('fs');
const readFilePromise = util.promisify(fs.readFile);
const statPromise = util.promisify(fs.stat);
const readFile = async (path) => {
// Check if the path exists.
const stats = await statPromise(path);
// Check if the path belongs to a file.
if (!stats.isFile())
throw new Error('The path does not belong to a file');
// Read file.
return await readFilePromise(path);
}
5. Use descriptive Error types
Let’s say we’re building an endpoint for a REST API that returns a product by id. A service will handle the logic and the controller will handle the request, call the service and build the response:
/* --- product.service.js --- */
const getById = async (id) => {
const product = await productModel.findById(id);
if (!product)
throw new Error('Product not found');
return product;
}
/* --- product.controller.js --- */
const getById = async (req, res) => {
try {
const product = await productService.getById(req.params.id);
return product;
}
catch (err) {
res.status(500).json({ error: err.message });
}
}
So, what’s the problem here? Imagine that the first line of our service (productModel.findById(id)
) throws a database or network related error, in the previous code the error will be handled exactly the same as a “not found” error. This will make the handling of the error more complicated for our client.
Also, an even bigger problem: We don’t want just any error to be returned to the client for security reasons (we may be exposing sensitive information).
How do we fix this?
The best way to handle this is to use different implementations of the Error class accordingly for each case. This can be achieved by building our own custom implementations or installing a library that already contains all the implementations of Error we need.
For REST APIs I like to use throw.js. It’s a really simple module that contains Errors matching the most common HTTP status codes. Each error defined by this module also includes the status code as a property.
Let’s see how the previous example will look like using throw.js
:
/* --- product.service.js --- */
const error = require('throw.js');
const getById = async (id) => {
const product = await productModel.findById(id);
if (!product)
throw new error.NotFound('Product not found');
return product;
}
/* --- product.controller.js --- */
const error = require('throw.js');
const getById = async (req, res) => {
try {
const product = await productService.getById(req.params.id);
return product;
}
catch (err) {
if (err instanceof error.NotFound)
res.status(err.statusCode).json({ error: err.message });
else
res.status(500).json({ error: 'Unexpected error' });
}
}
In this second approach we’ve achieved two things:
- Our controller now has enough information to understand the error and act accordingly.
- The REST API client will now also receive a status code that will also help them handle the error.
And we can even take this further by building a global error handler or middleware that handles all errors, so that we can clear that code from the controller. But that’s a thing for another article.
Here’s another module that implements the most common error types: node-common-errors.
Thoughts? 💬
Were this tips useful?
Would you like me to write about any other node.js related topics on the next article of the series?
What are your tips to write effective/clean node.js code?
I’ll like to hear your feedback!