So, there are thousands of articles floating around the internet about why callbacks are bad and you should be using Promises and async/await. As the popular saying goes, the answer to most of the opinions in world of programming is "It depends". There is no one right solution for any problem.
What I'm addressing here is a very simple problem. I need to run multiple async operations in a function, and I need the code to look clean and readable. I have a handler function of a POST request to create a new product. It is written in Express and does the following
const createProduct = (req, res, next) => {
// Check if the user is valid
// Check if the product name already exists
// Check if the store exists
// Save product to database
}
Promise based approach
A promise based approach would look something like this
const createProduct = (req, res, next) => {
const { id: userId } = req.user;
User.findOne({id: userId})
.then((user) => {
if (!user) {
console.log('User does not exist');
return res.status(400).json({
status: 'error',
message: 'User does not exist',
});
}
const { name, storeId, price } = req.body;
Product.findOne({name})
.then((product) => {
if (product) {
console.log('Product with the same name already exists');
return res.status(400).json({
status: 'error',
message: 'Product with the same name already exists',
});
}
Store.findOne({id: storeId})
.then((store) => {
if (!store) {
console.log('Store does not exist');
return res.status(400).json({
status: 'error',
message: 'Store does not exist',
})
}
// Valid product. Can be saved to db
const newProduct = new Product({
name,
storeId,
price,
});
newProduct.save()
.then(() => {
console.log('Product saved successfully');
return res.status(200).json({
status: 'success',
message: 'Product saved successfully',
});
})
.catch((err) => {
console.log('err');
next(err);
})
})
.catch((err) => {
console.log(err);
next(err);
})
})
.catch((err) => {
console.log(err);
next(err);
})
})
.catch((err) => {
console.log(err);
next(err);
})
}
Async-await based approach
And if you convert this to a async await
based approach, you would end up with something very similar.
const createProduct = async (req, res, next) => {
const { id: userId } = req.user;
try {
const user = await User.findOne({id: userId});
if (!user) {
console.log('User does not exist');
return res.status(400).json({
status: 'error',
message: 'User does not exist',
});
}
const { name, storeId, price } = req.body;
try {
const product = await Product.findOne({name});
if (product) {
console.log('Product with the same name already exists');
return res.status(400).json({
status: 'error',
message: 'Product with the same name already exists',
});
}
try {
const store = await Store.findOne({id: storeId});
if (!store) {
console.log('Store does not exist');
return res.status(400).json({
status: 'error',
message: 'Store does not exist',
})
}
try {
const newProduct = new Product({
name,
storeId,
price,
});
await newProduct.save();
console.log('Product saved successfully');
return res.status(200).json({
status: 'success',
message: 'Product saved successfully',
});
} catch (err) {
console.log('Error when saving product', err);
next(err);
}
} catch (err) {
console.log('Error when fetching store', err);
next(err);
}
} catch (err) {
console.log('Error when fetching product', err);
next(err);
}
} catch (err) {
console.log('Error when fetching user', err);
next(err);
}
}
There is nothing wrong with this approach and works pretty well for small functions. But when the number of async operations increase, the code goes into this pyramid structure which is hard to understand. Usually called the Pyramid of doom
.
Linear async-await
To overcome this and to give our code a linear structure, we can write a utility function which fires the promise and returns the error and success states.
const firePromise = (promise) => {
return promise
.then((data) => {
return [null, data];
})
.catch((err) => {
return [err, null];
})
}
We can pass any async operation which returns a promise to this function and it will give us error and success states in an array. Goes something like this.
const [error, user] = await firePromise(User.findOne({id: userId}));
Now we can refactor our createProduct
handler to use our firePromise
function.
const createProduct = async (req, res, next) => {
let error, user, product, store;
const { id: userId } = req.user;
try {
[error, user] = await firePromise(User.findOne({id: userId}));
if(error) {
console.log('Error when fetching user', error);
next(error);
}
if(!user) {
console.log('User does not exist');
return res.status(400).json({
status: 'error',
message: 'User does not exist',
});
}
const { name, storeId, price } = req.body;
[error, product] = await firePromise(Product.findOne({name}));
if(error) {
console.log('Error when fetching product', error);
next(error);
}
if (product) {
console.log('Product with the same name already exists');
return res.status(400).json({
status: 'error',
message: 'Product with the same name already exists',
});
}
[error, store] = await firePromise(Store.findOne({id: storeId}));
if(error) {
console.log('Error when fetching store', error);
next(error);
}
if (!store) {
console.log('Store does not exist');
return res.status(400).json({
status: 'error',
message: 'Store does not exist',
})
}
const newProduct = new Product({
name,
storeId,
price,
});
[error] = await firePromise(newProduct.save());
if (error) {
console.log('Error when saving product', err);
next(error);
}
console.log('Product saved successfully');
return res.status(200).json({
status: 'success',
message: 'Product saved successfully',
});
} catch (err) {
console.log('Unexpected error');
next(err);
}
}
In my opinion, this is much more readable because of its linear structure. This function can be used with any JS framework to write readable and maintainable async code.
This was inspired by await-to-js library, and I use it in almost all my JS projects. Go give them a star.
Cheers!
Top comments (0)