This article was originally published at https://maximorlov.com/linting-rules-for-asynchronous-code-in-javascript/
Debugging asynchronous code in JavaScript can feel like navigating a minefield at times. You don't know when and where the console.logs will print out, and you have no idea how your code is executed.
It's hard to correctly structure async code so it executes in the right order as you intend it to.
Wouldn't it be nice if you had some guidance while writing asynchronous code, and to get a helpful message when you're about to make a mistake?
Luckily we have linters to catch some of our bugs before we push them to production. The following is a compiled list of linting rules to specifically help you with writing asynchronous code in JavaScript and Node.js.
Even if you end up not using the rules in your project, reading their descriptions will lead to a better understanding of async code and improve your developer skills.
ESLint rules for asynchronous code
The following rules are shipped by default with ESLint. Enable them by adding them to your eslint configuration file.
1. no-async-promise-executor
This rule disallows passing an async
function to the new Promise
constructor.
// ❌
new Promise(async (resolve, reject) => {});
// ✅
new Promise((resolve, reject) => {});
While it's technically valid to pass an asynchronous function to the Promise constructor, doing so is usually a mistake for any of these two reasons. First, if the async function throws, the error will be lost and won't be rejected by the newly-constructed promise. Second, if await
is used inside the constructor function, the wrapping Promise might be unnecessary and you could remove it.
2. no-await-in-loop
This rule disallows using await
inside loops.
When doing an operation on each element of an iterable and awaiting an asynchronous task, it's oftentimes an indication that the program is not taking full advantage of JavaScript's event-driven architecture. By executing the tasks in parallel instead, you could greatly improve the efficiency of your code.
// ❌
for (const url of urls) {
const response = await fetch(url);
}
// ✅
const responses = [];
for (const url of urls) {
const response = fetch(url);
responses.push(response);
}
await Promise.all(responses);
In the odd case when you deliberately want to run tasks in sequence, I recommend temporarily disabling this rule with an inline comment: // eslint-disable-line no-await-in-loop
.
3. no-promise-executor-return
This rule disallows returning a value inside a Promise constructor.
// ❌
new Promise((resolve, reject) => {
return result;
});
// ✅
new Promise((resolve, reject) => {
resolve(result);
});
Values returned inside a Promise constructor cannot be used and don't affect the promise in any way. The value should be passed to resolve
instead, or if an error occurred, call reject
with the error.
Note: This rule won't prevent you from returning a value inside a nested callback within the Promise constructor. Always make sure to use
resolve
orreject
to conclude a promise.
4. require-atomic-updates
This rule disallows assignments in combination with await
, which can lead to race conditions.
Consider the example below, what do you think the final value of totalPosts
will be?
// ❌
let totalPosts = 0;
async function getPosts(userId) {
const users = [{ id: 1, posts: 5 }, { id: 2, posts: 3 }];
await sleep(Math.random() * 1000);
return users.find((user) => user.id === userId).posts;
}
async function addPosts(userId) {
totalPosts += await getPosts(userId);
}
await Promise.all([addPosts(1), addPosts(2)]);
console.log('Post count:', totalPosts);
Perhaps you've sensed that this was a trick question and the answer is not 8. That's right, totalPosts
prints either 5 or 3. Go ahead and try it yourself in the browser.
The issue lies in the fact that there is a time gap between reading and updating totalPosts
. This causes a race condition such that when the value is updated in a separate function call, the update is not reflected in the current function scope. Therefore both functions add their result to totalPosts
's initial value of 0.
To avoid this race condition you should make sure the variable is read at the same time it's updated.
// ✅
let totalPosts = 0;
async function getPosts(userId) {
const users = [{ id: 1, posts: 5 }, { id: 2, posts: 3 }];
await sleep(Math.random() * 1000);
return users.find((user) => user.id === userId).posts;
}
async function addPosts(userId) {
const posts = await getPosts(userId);
totalPosts += posts; // variable is read and immediately updated
}
await Promise.all([addPosts(1), addPosts(2)]);
console.log('Post count:', totalPosts);
5. max-nested-callbacks
This rule enforces a maximum nesting depth for callbacks. In other words, this rule prevents callback hell!
/* eslint max-nested-callbacks: ["error", 3] */
// ❌
async1((err, result1) => {
async2(result1, (err, result2) => {
async3(result2, (err, result3) => {
async4(result3, (err, result4) => {
console.log(result4);
});
});
});
});
// ✅
const result1 = await asyncPromise1();
const result2 = await asyncPromise2(result1);
const result3 = await asyncPromise3(result2);
const result4 = await asyncPromise4(result3);
console.log(result4);
A deep level of nesting makes code hard to read and more difficult to maintain. Refactor callbacks to promises and use modern async/await syntax when writing asynchronous code JavaScript.
6. no-return-await
This rule disallows unnecessary return await
.
// ❌
async () => {
return await getUser(userId);
}
// ✅
async () => {
return getUser(userId);
}
Awaiting a promise and immediately returning it is unnecessary since all values returned from an async
function are wrapped in a promise. Therefore you can return the promise directly.
An exception to this rule is when there is a surrounding try...catch
statement. Removing the await
keyword will cause a promise rejection to not be caught. In this case, I suggest you assign the result to a variable on a different line to make the intent clear.
// 👎
async () => {
try {
return await getUser(userId);
} catch (error) {
// Handle getUser error
}
}
// 👍
async () => {
try {
const user = await getUser(userId);
return user;
} catch (error) {
// Handle getUser error
}
}
7. prefer-promise-reject-errors
This rule enforces using an Error
object when rejecting a Promise.
// ❌
Promise.reject('An error occurred');
// ✅
Promise.reject(new Error('An error occurred'));
It's best practice to always reject a Promise with an Error
object. Doing so will make it easier to trace where the error came from because error objects store a stack trace.
Node.js specific rules
The following rules are additional ESLint rules for Node.js provided by the eslint-plugin-n
plugin. To use them, you need to install and add the plugin to the plugins
array in your eslint configuration file.
8. node/handle-callback-err
This rule enforces error handling inside callbacks.
// ❌
function callback(err, data) {
console.log(data);
}
// ✅
function callback(err, data) {
if (err) {
console.log(err);
return;
}
console.log(data);
}
In Node.js it's common to pass the error as the first parameter to a callback function. Forgetting to handle the error can result in your application behaving strangely.
This rule is triggered when the first argument of a function is named err
. In large projects, it's not uncommon to find different naming variations for errors such as e
or error
. You can change the default configuration by providing a second argument to the rule: node/handle-callback-err: ["error", "^(e|err|error)$"]
9. node/no-callback-literal
This rule enforces that a callback function is called with an Error
object as the first parameter. In case there's no error, null
or undefined
are accepted as well.
// ❌
cb('An error!');
callback(result);
// ✅
cb(new Error('An error!'));
callback(null, result);
This rule makes sure you don't accidentally invoke a callback function with a non-error as the first parameter. According to the error-first callback convention, the first argument of a callback function should be the error or otherwise null or undefined if there's no error.
The rule is triggered only when the function is named cb
or callback
.
10. node/no-sync
This rule disallows using synchronous methods from the Node.js core API where an asynchronous alternative exists.
// ❌
const file = fs.readFileSync(path);
// ✅
const file = await fs.readFile(path);
Using synchronous methods for I/O operations in Node.js blocks the event loop. In most web applications, you want to use asynchronous methods when doing I/O operations.
In some applications like a CLI utility or a script, using the synchronous method is okay. You can disable this rule at the top of the file with /* eslint-disable node/no-sync */
.
Additional rules for TypeScript users
If your project is using TypeScript you're probably familiar with TypeScript ESLint (previously TSLint). The following rules are available to TypeScript projects only because they infer extra context from typing information.
11. @typescript-eslint/await-thenable
This rule disallows awaiting a function or value that is not a Promise.
// ❌
function getValue() {
return someValue;
}
await getValue();
// ✅
async function getValue() {
return someValue;
}
await getValue();
While it's valid JavaScript to await a non-Promise value (it will resolve immediately), it's often an indication of a programmer error, such as forgetting to add parentheses to call a function that returns a Promise.
12. @typescript-eslint/no-floating-promises
This rule enforces Promises to have an error handler attached.
// ❌
myPromise()
.then(() => {});
// ✅
myPromise()
.then(() => {})
.catch(() => {});
This rule prevents floating Promises in your codebase. A floating Promise is a Promise that doesn't have any code to handle potential errors.
Always make sure to handle promise rejections otherwise your Node.js server will crash.
13. @typescript-eslint/no-misused-promises
This rule disallows passing a Promise to places that aren't designed to handle them, such as if-conditionals.
// ❌
if (getUserFromDB()) {}
// ✅ 👎
if (await getUserFromDB()) {}
// ✅ 👍
const user = await getUserFromDB();
if (user) {}
This rule prevents you from forgetting to await an async function in a place where it would be easy to miss.
While the rule does allow awaiting inside an if-conditional, I recommend assigning the result to a variable and using the variable in the conditional for improved readability.
14. @typescript-eslint/promise-function-async
This rule enforces Promise-returning functions to be async
.
// ❌
function doSomething() {
return somePromise;
}
// ✅
async function doSomething() {
return somePromise;
}
A non-async function that returns a promise can be problematic since it can throw an Error object and return a rejected promise. Code is usually not written to handle both scenarios. This rule makes sure a function returns a rejected promise or throws an Error, but never both.
Additionally, it's much easier to navigate a codebase when you know that all functions that return a Promise and are therefore asynchronous, are marked as async
.
Enable these rules in your project right now
I've published an ESLint configuration package that you can easily add to your project. It exports the base rules, Node.js specific rules, and TypeScript specific rules separately.
For non-Typescript users
Install the package and ESLint:
npm install --save-dev eslint eslint-config-async
Then in your eslint.config.js
configuration file add the following:
const asyncConfig = require("eslint-config-async");
module.exports = [
...asyncConfig.base, // enable base rules
...asyncConfig.node, // enable Node.js specific rules (recommended)
];
For TypeScript users
Install the package and its peer dependencies:
npm install --save-dev eslint eslint-config-async typescript typescript-eslint
In your eslint.config.js
configuration file:
const tseslint = require("typescript-eslint");
const asyncConfig = require("eslint-config-async");
module.exports = [
tseslint.configs.base, // adds the parser only, without any rules
...asyncConfig.base, // enable base rules
...asyncConfig.node, // enable Node.js specific rules (recommended)
...asyncConfig.typescript, // enable TypeScript specific rules
{
files: ["*.ts"], // tell ESLint to include TypeScript files
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
},
},
];
That's it! Add these linting rules for asynchronous code to your project and fix any issues that show up. You might squash a bug or two! 🐛 🚫
Master Asynchronous JavaScript 🚀
Learn how to write modern and easy-to-read asynchronous code with a FREE 5-day email course.
Through visual graphics you will learn how to decompose async code into individual parts and put them back together using a modern async/await approach. Moreover, with 30+ real-world exercises you'll transform knowledge into a practical skill that will make you a better developer.
Top comments (0)