Skip to editor content
learningtypescript.orglesson 19 of 25

Async TypeScript

Most real-world applications need to do things that take time — fetching data from an API, reading a file, waiting for user input. JavaScript handles this with an event loop that lets other work continue while waiting. TypeScript layers its type system on top, making async code safer and more predictable.

In this lesson you'll go from raw Promises to clean async/await syntax, learn how TypeScript types flow through async operations, and handle errors without losing type safety.

The Promise Type

A Promise<T> represents a value that will be available at some point in the future. The generic parameter T tells TypeScript what type that eventual value will be.

function fetchUserName(id: number): Promise<string> {
  return new Promise((resolve, reject) => {
    // Simulate a network delay
    setTimeout(() => {
      if (id <= 0) {
        reject(new Error("Invalid user ID"));
      } else {
        resolve(`User_${id}`);
      }
    }, 300);
  });
}

fetchUserName(42)
  .then((name) => console.log("Got:", name))
  .catch((err) => console.error("Failed:", err.message));

fetchUserName(-1)
  .then((name) => console.log("Got:", name))
  .catch((err) => console.error("Failed:", err.message));

resolve narrows the Promise to Promise<string> — TypeScript infers this from the type annotation on the function. Calling .then(name => ...) means name is already typed as string with no extra work.

Async / Await

Writing .then().catch() chains gets messy fast. The async and await keywords let you write asynchronous code that reads like synchronous code.

  • Mark a function with async and its return type automatically becomes a Promise
  • Use await inside an async function to pause execution until a Promise resolves
  • Wrap in try/catch to handle rejections
interface WeatherReport {
  city: string;
  tempC: number;
  condition: string;
}

async function getWeather(city: string): Promise<WeatherReport> {
  // Simulate an API call
  await new Promise((resolve) => setTimeout(resolve, 200));

  if (city === "") {
    throw new Error("City name cannot be empty");
  }

  return {
    city,
    tempC: Math.round(Math.random() * 30),
    condition: ["Sunny", "Cloudy", "Rainy"][Math.floor(Math.random() * 3)],
  };
}

async function displayWeather(): Promise<void> {
  try {
    const report = await getWeather("Amsterdam");
    console.log(`${report.city}: ${report.tempC}°C, ${report.condition}`);
  } catch (err) {
    if (err instanceof Error) {
      console.error("Could not load weather:", err.message);
    }
  }
}

displayWeather();

Notice that getWeather is typed as returning Promise<WeatherReport>, but inside the async function you just return a plain WeatherReport object — TypeScript wraps it automatically.

Typed Error Handling

TypeScript treats caught errors as unknown by default (with useUnknownInCatchVariables, enabled in strict mode). This forces you to narrow the type before accessing properties — which is exactly what you want.

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

async function loadProduct(id: number): Promise<{ id: number; name: string }> {
  await new Promise((resolve) => setTimeout(resolve, 100));

  if (id === 404) {
    throw new ApiError(404, "Product not found");
  }
  if (id === 500) {
    throw new ApiError(500, "Internal server error");
  }

  return { id, name: `Product #${id}` };
}

async function run(): Promise<void> {
  for (const id of [1, 404, 500]) {
    try {
      const product = await loadProduct(id);
      console.log(`Loaded: ${product.name}`);
    } catch (err) {
      if (err instanceof ApiError) {
        console.error(`[${err.statusCode}] ${err.message}`);
      } else if (err instanceof Error) {
        console.error(`Unexpected error: ${err.message}`);
      }
    }
  }
}

run();

Using a custom error class gives you typed properties like statusCode that plain Error doesn't have — and the instanceof check satisfies TypeScript's type narrower.

Running Promises in Parallel

await pauses execution — if you await multiple Promises one after another, you lose concurrency. Use Promise.all to run them in parallel and wait for all to finish.

async function fetchScore(player: string): Promise<number> {
  await new Promise((resolve) => setTimeout(resolve, Math.random() * 200 + 50));
  return Math.floor(Math.random() * 100);
}

async function getLeaderboard(players: string[]): Promise<void> {
  // Sequential — each waits for the previous
  console.time("sequential");
  for (const player of players) {
    const score = await fetchScore(player);
    console.log(`${player}: ${score}`);
  }
  console.timeEnd("sequential");

  // Parallel — all run at once
  console.time("parallel");
  const scores = await Promise.all(players.map(fetchScore));
  players.forEach((player, i) => console.log(`${player}: ${scores[i]}`));
  console.timeEnd("parallel");
}

getLeaderboard(["Alice", "Bob", "Carol", "Dave"]);

Promise.all returns Promise<number[]> here — TypeScript infers the tuple/array type from what you pass in. If you pass an array of Promise<string>, you get back string[].

When one Promise in Promise.all rejects, the entire call rejects. Use Promise.allSettled when you want results from all Promises regardless of failures — it returns an array of { status: 'fulfilled', value } or { status: 'rejected', reason } objects.

Try It Yourself

Implement a retry wrapper that attempts an async operation up to maxAttempts times, waiting delayMs between each try. On every failure it should log which attempt failed, and if all attempts are exhausted it should throw the last error.

async function unstableOperation(): Promise<string> {
  await new Promise((resolve) => setTimeout(resolve, 100));
  if (Math.random() < 0.7) {
    throw new Error("Flaky failure");
  }
  return "Success!";
}

async function withRetry<T>(
  operation: () => Promise<T>,
  maxAttempts: number,
  delayMs: number
): Promise<T> {
  let lastError: Error = new Error("No attempts made");

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const result = await operation();
      console.log(`Succeeded on attempt ${attempt}`);
      return result;
    } catch (err) {
      lastError = err instanceof Error ? err : new Error(String(err));
      console.log(`Attempt ${attempt} failed: ${lastError.message}`);

      if (attempt < maxAttempts) {
        await new Promise((resolve) => setTimeout(resolve, delayMs));
      }
    }
  }

  throw lastError;
}

async function main(): Promise<void> {
  try {
    const result = await withRetry(unstableOperation, 5, 150);
    console.log("Final result:", result);
  } catch (err) {
    if (err instanceof Error) {
      console.error("All attempts failed:", err.message);
    }
  }
}

main();

Run it a few times — because unstableOperation fails 70% of the time, you'll see different numbers of retries. The generic <T> on withRetry means it works with any async operation returning any type, and TypeScript tracks that type all the way through.

Key Takeaways

  • Promise<T> carries its resolved type as a generic parameter — TypeScript knows what .then() will receive
  • async functions always return a Promise; you can return a plain value and TypeScript wraps it
  • await unwraps a Promise<T> to T, making async code read like synchronous code
  • Caught errors are unknown in strict mode — use instanceof to narrow before accessing properties
  • Promise.all runs Promises in parallel and preserves types; use it instead of sequential await when operations are independent
  • Promise.allSettled is safer when you need results from all Promises regardless of individual failures

Pro Tip: Avoid the async keyword on functions that don't use await inside them — they still return a Promise, which adds overhead and can hide the function's synchronous nature from callers. If a function just wraps a value or calls another async function with a direct return, keep it synchronous or return the Promise directly without wrapping it in another async layer.

Next Steps

Async code often fetches data from APIs or user input — data you can't trust at compile time. Next, you'll learn how Zod lets you define schemas that validate data at runtime and automatically infer TypeScript types, closing the gap between compile-time safety and runtime reality.

Next lesson

Zod And Validation

Learn Zod for runtime schema validation in TypeScript. Parse untrusted data safely and infer types from schemas automatically.

25 min