javascriptroom guide

Working with Asynchronous Code in TypeScript

Asynchronous programming is a cornerstone of modern JavaScript and TypeScript development, enabling non-blocking operations like API calls, file I/O, and timers. Unlike synchronous code—where operations block execution until completion—async code lets your program continue running while waiting for long-running tasks. TypeScript, with its static typing, elevates async programming by catching errors early, improving readability, and ensuring type safety. In this guide, we’ll explore asynchronous patterns in TypeScript, from traditional callbacks to modern `async/await`, and dive into advanced strategies, error handling, and best practices.

Table of Contents

  1. Understanding Asynchronous Code in JavaScript/TypeScript
  2. Callbacks: The Traditional Approach
  3. Promises: A Better Way to Handle Async
  4. Async/Await: Syntactic Sugar for Promises
  5. Advanced Asynchronous Patterns
  6. Error Handling Strategies
  7. TypeScript-Specific Enhancements
  8. Best Practices
  9. Conclusion
  10. References

1. Understanding Asynchronous Code in JavaScript/TypeScript

Before diving into TypeScript specifics, let’s recap why asynchronous code matters. JavaScript (and thus TypeScript) is single-threaded, meaning it can only execute one operation at a time. Without async code, blocking operations (e.g., fetching data from an API) would freeze the entire program.

How Asynchronous Code Works

The event loop is the engine behind async behavior. It manages a queue of tasks (e.g., timer callbacks, API responses) and executes them when the main thread is idle. Key async operations include:

  • Network requests (e.g., fetch).
  • File system operations (e.g., fs.readFile in Node.js).
  • Timers (e.g., setTimeout).

2. Callbacks: The Traditional Approach

Callbacks were the original way to handle async code in JavaScript. A callback is a function passed as an argument to another function, which is executed once the async operation completes.

Example: Callback-Based Timer

// Define a callback function
const onTimeout = (message: string) => {
  console.log(message);
};

// Async function accepting a callback
const delayedGreeting = (callback: (msg: string) => void, delayMs: number) => {
  setTimeout(() => {
    callback("Hello after delay!"); // Execute callback when done
  }, delayMs);
};

// Usage
delayedGreeting(onTimeout, 2000); // Logs "Hello after delay!" after 2s

The Problem: Callback Hell

Callbacks work for simple cases but become unmanageable with nested async operations (e.g., fetching data, then processing it, then saving to a database). This is called “callback hell” or “pyramid of doom”:

// Callback hell example (hard to read/maintain)
fetchUser(userId, (user) => {
  fetchPosts(user.id, (posts) => {
    fetchComments(posts[0].id, (comments) => {
      console.log("Comments:", comments);
    }, (err) => handleError(err));
  }, (err) => handleError(err));
}, (err) => handleError(err));

This nested structure leads to poor readability, error-prone error handling, and difficulty debugging.

3. Promises: A Better Way to Handle Async

Promises (introduced in ES2015) solve callback hell by representing the eventual completion (or failure) of an async operation. A promise has three states:

  • Pending: Initial state (operation in progress).
  • Fulfilled: Operation succeeded (value available).
  • Rejected: Operation failed (error available).

Creating a Promise

A promise is created with a executor function, which takes two callbacks: resolve (for success) and reject (for failure).

// Promise-based version of delayedGreeting
const delayedGreetingPromise = (delayMs: number): Promise<string> => {
  return new Promise((resolve, reject) => {
    if (delayMs < 0) {
      reject(new Error("Delay cannot be negative")); // Reject on error
    }
    setTimeout(() => {
      resolve("Hello after delay!"); // Resolve with value
    }, delayMs);
  });
};

Consuming Promises with .then(), .catch(), and .finally()

Promises are consumed using:

  • .then(): Handles fulfillment (takes a success callback).
  • .catch(): Handles rejection (takes an error callback).
  • .finally(): Runs cleanup code (executes regardless of success/failure).
// Consuming the promise
delayedGreetingPromise(2000)
  .then((message) => {
    console.log(message); // Logs "Hello after delay!"
    return message.toUpperCase(); // Chain another .then()
  })
  .then((upperMessage) => {
    console.log(upperMessage); // Logs "HELLO AFTER DELAY!"
  })
  .catch((error) => {
    console.error("Error:", error.message); // Catches errors
  })
  .finally(() => {
    console.log("Operation complete (success or failure)");
  });

Chaining Promises

Promise chaining eliminates callback hell by flattening nested operations:

// Chaining replaces nested callbacks
fetchUserPromise(userId)
  .then((user) => fetchPostsPromise(user.id))
  .then((posts) => fetchCommentsPromise(posts[0].id))
  .then((comments) => console.log("Comments:", comments))
  .catch((err) => handleError(err)); // Single error handler for the chain

4. Async/Await: Syntactic Sugar for Promises

Introduced in ES2017, async/await is syntactic sugar over promises, making async code read like synchronous code. It’s widely adopted for its readability.

Basic Syntax

  • async keyword: Marks a function as asynchronous. It always returns a promise.
  • await keyword: Pauses execution until the promise resolves (can only be used inside async functions).

Example: Async/Await with the Delayed Greeting

// Async function using await
const greetWithAsyncAwait = async () => {
  try {
    const message = await delayedGreetingPromise(2000); // Pause until resolved
    console.log(message); // "Hello after delay!"
    const upperMessage = message.toUpperCase();
    console.log(upperMessage); // "HELLO AFTER DELAY!"
  } catch (error) {
    console.error("Error:", (error as Error).message); // Catch rejections
  } finally {
    console.log("Operation complete");
  }
};

// Execute the async function
greetWithAsyncAwait();

Why Async/Await is Better

  • Readability: Code flows linearly, like synchronous code.
  • Error Handling: Uses try/catch instead of .catch(), which feels more natural.
  • Simpler Chaining: Avoids long .then() chains.

5. Advanced Asynchronous Patterns

TypeScript (and JavaScript) provide utility methods to handle complex async scenarios like parallel execution, racing promises, or waiting for all promises to settle.

Promise.all(): Run Promises in Parallel

Executes multiple promises in parallel and resolves when all are fulfilled. Rejects immediately if any promise rejects.

Use Case: Fetching multiple independent resources at once.

const fetchUser = (id: number): Promise<User> => 
  fetch(`/api/users/${id}`).then(res => res.json());

const fetchPosts = (userId: number): Promise<Post[]> => 
  fetch(`/api/users/${userId}/posts`).then(res => res.json());

// Run in parallel
const fetchDataParallel = async () => {
  try {
    const [user, posts] = await Promise.all([
      fetchUser(1), 
      fetchPosts(1)
    ]);
    console.log("User:", user);
    console.log("Posts:", posts);
  } catch (error) {
    console.error("One or more requests failed:", error);
  }
};

Promise.allSettled(): Wait for All to Settle

Resolves when all promises settle (either fulfilled or rejected). Returns an array of results with status ("fulfilled" or "rejected") and value/reason.

Use Case: When you need results from all operations, even if some fail (e.g., batch processing).

const results = await Promise.allSettled([
  Promise.resolve("Success"),
  Promise.reject(new Error("Failure"))
]);

results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log("Success:", result.value);
  } else {
    console.log("Failed:", result.reason.message);
  }
});

Promise.race(): Resolve/Reject with the First Settled Promise

Resolves or rejects as soon as any promise in the array settles.

Use Case: Timeouts for async operations (e.g., cancel a request if it takes too long).

const fetchWithTimeout = async (url: string, timeoutMs: number) => {
  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error("Request timed out")), timeoutMs)
  );

  return Promise.race([
    fetch(url).then(res => res.json()), // Actual request
    timeoutPromise // Timeout "race" condition
  ]);
};

// Usage: Fails if fetch takes > 3s
fetchWithTimeout("/api/data", 3000);

Promise.resolve() and Promise.reject()

  • Promise.resolve(value): Creates a resolved promise with value.
  • Promise.reject(error): Creates a rejected promise with error.

Use Case: Wrapping synchronous values/errors in promises.

const resolvedPromise = Promise.resolve(42); // Promise<number>
const rejectedPromise = Promise.reject(new Error("Oops")); // Promise<never>

6. Error Handling Strategies

Robust error handling is critical for async code. TypeScript helps by enforcing error types, but you need to use the right patterns.

1. try/catch with Async/Await

The most common approach. Wrap await calls in try/catch to handle rejections:

const safeFetch = async (url: string) => {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`); // Throw custom error
    }
    return await response.json();
  } catch (error) {
    console.error("Fetch failed:", (error as Error).message);
    throw error; // Re-throw to let caller handle it
  }
};

2. Handling Errors in Promise.all()

If any promise in Promise.all() rejects, the entire Promise.all() rejects immediately. To handle partial failures, use Promise.allSettled() instead:

const fetchAllResources = async () => {
  const results = await Promise.allSettled([
    fetch("/api/resource1"),
    fetch("/api/resource2"),
    fetch("/api/resource3")
  ]);

  const successfulResponses = results
    .filter((r): r is PromiseFulfilledResult<Response> => r.status === "fulfilled")
    .map(r => r.value);

  const failedResponses = results
    .filter((r): r is PromiseRejectedResult => r.status === "rejected")
    .map(r => r.reason);

  console.log("Successful:", successfulResponses);
  console.log("Failed:", failedResponses);
};

3. Type-Safe Errors with TypeScript

Define custom error types to make errors more descriptive and type-safe:

class ApiError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.name = "ApiError";
    this.statusCode = statusCode;
  }
}

// Usage in fetch
const fetchData = async () => {
  const response = await fetch("/api/data");
  if (!response.ok) {
    throw new ApiError(`Failed to fetch: ${response.statusText}`, response.status);
  }
  return response.json();
};

// Catching with type guards
try {
  await fetchData();
} catch (error) {
  if (error instanceof ApiError) {
    console.error(`API Error (${error.statusCode}): ${error.message}`); // Type-safe access
  } else {
    console.error("Unexpected error:", error);
  }
}

7. TypeScript-Specific Enhancements

TypeScript adds type safety to async code, catching errors at compile time instead of runtime.

Typing Promises

Explicitly define promise return types using Promise<T>, where T is the type of the resolved value:

// Explicitly typed promise function
const fetchUser = (id: number): Promise<User> => {
  return fetch(`/api/users/${id}`).then(res => res.json() as Promise<User>);
};

// TypeScript infers the return type as Promise<User>
const fetchUserInferred = async (id: number) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as Promise<User>;
};

Async Function Return Types

An async function always returns a promise. TypeScript infers this automatically, but you can explicitly define it:

// Explicit return type (redundant here, but useful for clarity)
const getGreeting = async (): Promise<string> => {
  return "Hello, TypeScript!"; // Resolves to string
};

// Returns Promise<number>
const addAsync = async (a: number, b: number): Promise<number> => a + b;

Generics with Promises

Use generics to create reusable async utilities. For example, a generic fetchJson function:

// Generic fetch function
const fetchJson = async <T>(url: string): Promise<T> => {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>; // Cast response to T
};

// Usage with a User interface
interface User {
  id: number;
  name: string;
}

const user = await fetchJson<User>("/api/users/1"); // user is type User

8. Best Practices

Follow these practices to write clean, maintainable, and error-free async code in TypeScript.

1. Always Handle Rejections

Uncaught promise rejections crash Node.js apps and log errors in browsers. Use try/catch (with async/await) or .catch() (with promises) for every async operation.

2. Prefer async/await Over Raw Promises

async/await is more readable than .then() chains. Reserve .then() for simple one-off operations.

3. Parallelize When Possible

Use Promise.all() for independent async operations to reduce total execution time. Avoid sequential execution unless operations depend on each other.

4. Avoid Blocking the Event Loop

Async code should never block the event loop (e.g., with heavy synchronous computations). Offload blocking work to worker threads (Node.js) or Web Workers (browsers).

5. Use TypeScript Types Everywhere

Leverage Promise<T>, interfaces, and type guards to make async code type-safe. This catches errors like missing properties in API responses early.

6. Limit Promise.race() Usage

Promise.race() can lead to orphaned promises (e.g., a request that continues running after the race is won). Use it only when necessary (e.g., timeouts).

9. Conclusion

Asynchronous code is essential for building responsive applications, and TypeScript makes it safer and more maintainable. From callbacks to promises to async/await, we’ve covered the evolution of async patterns and how TypeScript enhances them with types.

By mastering promises, async/await, and advanced utilities like Promise.all(), you’ll write clean, efficient async code. Remember to prioritize error handling, leverage TypeScript’s type system, and follow best practices to avoid common pitfalls.

10. References