Table of Contents
- Introduction
- The Basics of Error Handling
- Using Try/Catch with Async Functions
- Handling Multiple Async Operations
- Returning Errors from Async Functions
- Handling Errors with
Promise.all
andPromise.race
- Graceful Error Handling Strategies
- Conclusion
Introduction
Asynchronous code is a staple in modern JavaScript and TypeScript programming. Whether you’re fetching data from an API, interacting with a database, or performing any other async operation, error handling is crucial to ensure that your application remains robust and user-friendly. However, handling errors in asynchronous code can be tricky.
In this article, we will explore different error handling patterns for async code in TypeScript, including try/catch
blocks, Promise.all
, Promise.race
, and how to gracefully manage errors without losing control of the application flow.
The Basics of Error Handling
In synchronous JavaScript or TypeScript, errors can be easily caught using try/catch
blocks. However, when dealing with asynchronous code, things get more complicated. The primary reason is that asynchronous operations run in the background, and by the time the error occurs, the execution flow may have already moved on.
Common Mistakes in Async Error Handling
A few common mistakes when handling errors in async code include:
- Not catching promise rejections (unhandled promise rejections).
- Forgetting to handle errors in parallel async operations.
- Swallowing errors silently without logging or propagating them.
By understanding and applying error handling patterns, you can avoid these issues and improve the reliability of your async code.
Using Try/Catch with Async Functions
The most basic and widely used method to handle errors in async code is the try/catch
block. This method is synchronous and can easily be used within async
functions to handle exceptions that occur during the execution of asynchronous operations.
Basic try/catch
with Async/Await
async function fetchData(): Promise<string> {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error("Failed to fetch data");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error occurred while fetching data:", error);
throw error; // Re-throw the error for further handling upstream
}
}
fetchData()
.then(result => console.log(result))
.catch(error => console.log("Caught an error in the caller:", error));
In the above example:
- The
try/catch
block is used to catch errors that might occur during theawait
calls. - Errors, such as network issues or non-200 HTTP responses, are caught and logged.
- After catching the error, we can either handle it or propagate it further by throwing it again.
Best Practices
- Catch specific errors: Catch the most specific error possible to ensure the error message is meaningful.
- Log errors: Always log the error details for debugging purposes.
- Re-throw the error: If needed, re-throw the error so it can be handled by a higher-level error handler.
Handling Multiple Async Operations
When dealing with multiple asynchronous operations, error handling becomes more complex. There are several approaches to handle errors when multiple promises are executed simultaneously.
Using Promise.all
Promise.all
runs multiple promises in parallel and waits for all of them to resolve or any of them to reject. If any promise rejects, Promise.all
immediately rejects with that error.
async function fetchMultipleData(): Promise<void> {
try {
const [userData, productData] = await Promise.all([
fetchUserData(),
fetchProductData(),
]);
console.log(userData, productData);
} catch (error) {
console.error("Error fetching data:", error);
}
}
fetchMultipleData();
In this case:
- If either
fetchUserData()
orfetchProductData()
fails, the entirePromise.all
fails, and thecatch
block will execute. - It’s crucial to handle errors properly when using
Promise.all
because the first failure cancels all other promises.
Using Promise.allSettled
Promise.allSettled
allows all promises to complete, regardless of whether they resolve or reject. It returns an array of objects describing the result of each promise (either fulfilled
or rejected
).
async function fetchAllData(): Promise<void> {
const results = await Promise.allSettled([
fetchUserData(),
fetchProductData(),
]);
results.forEach(result => {
if (result.status === "fulfilled") {
console.log("Data fetched:", result.value);
} else {
console.error("Error fetching data:", result.reason);
}
});
}
fetchAllData();
Here:
- Even if one or more promises fail,
Promise.allSettled
ensures all promises finish, and each result is handled individually.
When to Use:
- Use
Promise.all
when you need all promises to succeed. - Use
Promise.allSettled
when you want to continue processing even if some promises fail.
Returning Errors from Async Functions
It’s essential to consider how errors are returned from async functions. By convention, async
functions return a Promise
, and that promise resolves with the result or rejects with an error.
Handling Rejections
If you expect that an async function might reject, ensure that the caller of the function handles the rejection properly, either with .catch()
or try/catch
blocks.
async function getUserById(id: number): Promise<User> {
const response = await fetch(`/api/user/${id}`);
if (!response.ok) {
throw new Error("User not found");
}
return response.json();
}
getUserById(123)
.then(user => console.log(user))
.catch(error => console.error("Error fetching user:", error));
- Graceful rejection handling: Catch the rejection in the caller to handle it appropriately and provide feedback to the user.
Custom Error Handling
In some cases, you may need to define custom error classes to handle specific errors more gracefully:
class FetchError extends Error {
constructor(message: string) {
super(message);
this.name = "FetchError";
}
}
async function fetchData(): Promise<any> {
try {
const data = await fetch("https://api.example.com/data");
if (!data.ok) {
throw new FetchError("Failed to fetch data");
}
return data.json();
} catch (error) {
if (error instanceof FetchError) {
console.error(error.message);
} else {
console.error("An unknown error occurred", error);
}
throw error;
}
}
This pattern allows you to identify and handle different error types more clearly.
Handling Errors with Promise.race
Promise.race
runs multiple promises in parallel but returns the result of the first promise to resolve (or reject). It is useful when you want to manage timeouts or race conditions.
Example with Timeout
function fetchDataWithTimeout(url: string, timeout: number): Promise<any> {
const fetchPromise = fetch(url).then(response => response.json());
const timeoutPromise = new Promise<any>((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), timeout)
);
return Promise.race([fetchPromise, timeoutPromise]);
}
fetchDataWithTimeout("https://api.example.com/data", 5000)
.then(data => console.log("Data fetched:", data))
.catch(error => console.error("Error:", error));
Here:
- If the fetch takes longer than the timeout,
Promise.race
ensures the timeout error is thrown first.
Graceful Error Handling Strategies
Here are some strategies for graceful error handling in async code:
- User-Friendly Error Messages: Always provide helpful, user-friendly error messages when errors are thrown. For example, instead of a generic “Something went wrong”, say “Unable to fetch data. Please check your connection.”
- Logging Errors: Ensure you log errors for debugging purposes, but avoid exposing stack traces to the user in production environments.
- Retries for Network Errors: For network-related errors, consider implementing retry logic to handle intermittent issues.
- Fallback Data: If your app relies on external data, consider having fallback data or functionality to continue operating even when the external resource fails.
Conclusion
Error handling in async code is critical to maintaining the stability and usability of your application. By using patterns such as try/catch
, Promise.all
, Promise.allSettled
, and custom error handling, you can ensure that errors are dealt with gracefully and consistently.
Remember to:
- Handle errors as early as possible.
- Provide useful error messages.
- Always ensure that asynchronous errors are caught and managed appropriately.
By implementing these patterns, your asynchronous code will be more reliable, and you will avoid common pitfalls that could otherwise lead to silent failures or difficult-to-trace bugs.