Clean Code: Taming Asynchronous Code with Async/Await
If you've been writing JavaScript for a while, you've probably run into situations where you need to do something after something else has finished. This is the world of asynchronous programming. In the old days, we had callbacks, which often led to the dreaded "callback hell" - a pyramid of nested functions that was hard to read and even harder to debug.
Then came Promises, which were a big improvement. But the real game-changer was async/await
. It's syntactic sugar on top of Promises, and it lets us write asynchronous code that reads like a simple, top-down story.
Let's see it in action.
From Promise Chains to Clean Code
Imagine you need to fetch a user and then their posts. With Promises, you might end up with a chain of .then()
calls.
interface User {
id: string;
name: string;
}
interface Post {
id: string;
title: string;
}
interface UserWithPosts extends User {
posts: Post[];
}
// Bad - Nested promises and unclear types
function fetchUserWithPosts(userId: string): Promise<UserWithPosts | null> {
return fetch(`/api/users/${userId}`)
.then(response => response.json())
.then((user: User) => {
return fetch(`/api/users/${user.id}/posts`)
.then(response => response.json())
.then((posts: Post[]) => ({ ...user, posts }));
})
.catch(error => {
console.error('Error:', error);
return null;
});
}
// Good - Async/await and clear types
async function fetchUserWithPosts(userId: string): Promise<UserWithPosts | null> {
try {
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) throw new Error('User not found');
const user: User = await userResponse.json();
const postsResponse = await fetch(`/api/users/${user.id}/posts`);
if (!postsResponse.ok) throw new Error('Posts not found');
const posts: Post[] = await postsResponse.json();
return { ...user, posts };
} catch (error) {
console.error('Error fetching user or posts:', error);
return null;
}
}
The async/await
version is much easier to scan. It clearly says: "first, get the user, then get their posts". The try/catch
block gives us a standard way to handle errors from either of the fetch
calls.
Running Operations in Parallel
What if you need to run multiple asynchronous operations at once? Doing them one by one can be slow. async/await
works beautifully with Promise.all
to help you run things in parallel.
// Bad: Fetching users one by one is inefficient.
async function fetchAllUserNames(userIds: string[]): Promise<string[]> {
const names: string[] = [];
for (const id of userIds) {
const user = await fetchUserById(id);
if (user) names.push(user.name);
}
return names;
}
// Good: Using Promise.all to fetch all users concurrently.
async function fetchAllUserNames(userIds: string[]): Promise<string[]> {
const userPromises = userIds.map(id => fetchUserById(id));
const users = await Promise.all(userPromises);
return users.filter(user => user !== null).map(user => user!.name);
}
async function fetchUserById(id: string): Promise<User | null> {
// Simulate API call
return { id, name: `User ${id}` };
}
Quick Tips
- Always handle errors. An unhandled promise rejection can crash your application. Always wrap your
await
calls in atry/catch
block or make sure the function that contains them has a.catch()
attached. async
all the way. When a function isasync
, it always returns aPromise
. This means you canawait
it in otherasync
functions.- Parallelize with
Promise.all
. If you have multiple promises that don't depend on each other, run them in parallel for a nice performance boost.
async/await
is one of the most significant improvements to JavaScript in recent years. It cleans up asynchronous code, reduces cognitive load, and helps you write more maintainable applications.