async/await is a fantastic syntax sugar over Promises, making asynchronous code look and feel synchronous. However, it introduces a new challenge: how do you handle errors elegantly? The standard try...catch block works, but it can quickly lead to verbose and repetitive code. Let's explore better patterns.
The Standard Approach: try...catch
The most direct way to handle a rejected promise in an async function is with a try...catch block.
async function fetchUserData(userId) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('User not found');
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Failed to fetch user:', error.message);
}
}This is fine for a single call, but what if you have multiple await calls? Nesting try...catch blocks is messy.
Pattern 1: Controller-Level try...catch
A cleaner approach is to let your functions throw errors and wrap the initial call in a single try...catch block. This is common in application controllers (like in an Express.js app).
// A utility function that can throw an error
async function getUser(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) throw new Error('API request failed');
return response.json();
}
// The main function that orchestrates the logic
async function displayUser(userId) {
try {
const user = await getUser(userId);
// update UI with user data
} catch (error) {
// update UI with error state
console.error(error);
}
}Pattern 2: The Go-inspired Error Tuple
The Go programming language has a popular pattern of returning both a result and an error. We can emulate this in JavaScript to avoid try...catch altogether.
async function safeAwait(promise) {
try {
const data = await promise;
return [data, null]; // [result, error]
} catch (error) {
return [null, error]; // [result, error]
}
}
// How to use it:
async function fetchAndDisplayUser(userId) {
const [user, error] = await safeAwait(fetch(`https://api.example.com/users/${userId}`));
if (error) {
console.error('An error occurred:', error);
return;
}
console.log('User data:', user);
}This pattern makes error handling explicit and easy to follow.
Handling Multiple Promises: Promise.allSettled
What if you need to run multiple promises in parallel and one might fail? Promise.all will reject immediately if any promise fails, losing the results of the successful ones.
Promise.allSettled is the perfect solution. It waits for all promises to either fulfill or reject and returns an array of objects describing the outcome of each.
const urls = ['/api/user', '/api/posts', '/api/invalid-endpoint'];
const promises = urls.map(url => fetch(url));
async function fetchAllData() {
const results = await Promise.allSettled(promises);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`URL ${urls[index]} succeeded with value:`, result.value);
} else {
console.error(`URL ${urls[index]} failed with reason:`, result.reason);
}
});
}Conclusion
Effective error handling is crucial for building robust applications. By moving beyond basic try...catch blocks and adopting patterns like controller-level handling, error tuples, and Promise.allSettled, you can write cleaner, more resilient asynchronous JavaScript. Thank you for reading! 🎉 Feel free to give a suggestion or send me a message on Twitter or LinkedIn.
