Designing TypeScript Systems to Handle Asynchronous Error Propagation Gracefully

Learn how to effectively handle and propagate asynchronous errors in TypeScript with beginner-friendly patterns and practical examples.

Handling errors in asynchronous code can be tricky, especially for those new to TypeScript or JavaScript. When dealing with promises or async/await patterns, it's important to catch and propagate errors properly without breaking the flow of your application. This article will guide you through designing TypeScript systems that handle asynchronous error propagation gracefully and in a beginner-friendly way.

The key to managing asynchronous errors is to use try/catch blocks within your async functions and to propagate errors to calling functions so they can be handled appropriately. TypeScript’s type system can also help you by making error handling more explicit and predictable.

Here is a simple example of an asynchronous function using async/await with proper error handling:

typescript
async function fetchData(url: string): Promise<string> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.text();
    return data;
  } catch (error) {
    // Here you can log the error or transform it
    console.error('fetchData error:', error);
    throw error; // Re-throw to propagate error
  }
}

In the example above, errors from the fetch call or any other part of the function are caught inside the catch block. The error is logged and then re-thrown to propagate it to the caller. This way, the caller knows that the async operation failed and can handle the error accordingly.

Let's see how to call this function safely, also handling errors properly:

typescript
async function handleData() {
  try {
    const data = await fetchData('https://jsonplaceholder.typicode.com/posts/1');
    console.log('Data received:', data);
  } catch (error) {
    console.error('Error handling data:', error);
    // Display friendly message or recover gracefully
  }
}

handleData();

To make your error handling system more robust and maintainable, consider defining custom error types. This helps you distinguish between different failure cases and respond appropriately.

typescript
class NetworkError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

async function fetchWithCustomError(url: string): Promise<string> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new NetworkError(`Failed with status ${response.status}`);
    }
    return await response.text();
  } catch (error) {
    if (error instanceof NetworkError) {
      // Handle specific network error
      console.error('NetworkError caught:', error.message);
    }
    throw error; // re-throw to propagate
  }
}

Finally, for advanced scenarios or larger projects, you might want to introduce utility functions or libraries that wrap asynchronous calls and return an object indicating success or failure, instead of throwing errors directly. This pattern is often called Result or Either.

typescript
type Result<T> = { data: T; error: null } | { data: null; error: Error };

async function fetchResult(url: string): Promise<Result<string>> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      return { data: null, error: new Error(`HTTP error: ${response.status}`) };
    }
    const data = await response.text();
    return { data, error: null };
  } catch (error) {
    return { data: null, error: error instanceof Error ? error : new Error('Unknown error') };
  }
}

(async () => {
  const result = await fetchResult('https://jsonplaceholder.typicode.com/posts/1');

  if (result.error) {
    console.error('Fetch failed:', result.error.message);
  } else {
    console.log('Fetch succeeded:', result.data);
  }
})();

This approach lets you handle success and error results uniformly without using try/catch at every call site, improving readability and control flow.

In summary, to design TypeScript systems that handle asynchronous error propagation gracefully, use try/catch blocks in async functions, consider defining custom error classes, and explore result wrapper patterns for more complex scenarios. These techniques allow your applications to be more robust, readable, and easier to maintain.