Skip to main content
TypeScript intermediate Lesson 15 of 21

Async TypeScript

Type Promises, async/await, fetch calls, error handling patterns, and concurrent async operations in TypeScript.

Typing Promises

Promises are the foundation of async JavaScript, and TypeScript’s Promise<T> generic makes them genuinely useful — the T parameter tells TypeScript what type you get when the promise resolves. Without it, you’d get any out of every async call and lose all type safety. TypeScript can infer this type from your function body, but explicit annotations are worth writing for public APIs because they document the contract and catch mistakes at the call site.

// Inferred return type: Promise<number>
async function fetchCount(): Promise<number> {
  const response = await fetch("/api/count");
  const data = await response.json();
  return data.count as number;
}

// Explicit annotation — good for public APIs
// The return type makes clear this can legitimately return null (user not found)
async function getUser(id: number): Promise<User | null> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (res.status === 404) return null;
    return res.json() as Promise<User>;
  } catch {
    return null;
  }
}

async/await

The async/await syntax lets you write asynchronous code that reads like synchronous code, eliminating deeply nested .then() chains. Inside an async function, await unwraps a Promise<T> to T — TypeScript tracks this transformation, so the variable after await always has the resolved type, not the Promise wrapper type.

interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

async function getPostTitle(id: number): Promise<string> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);

  if (!res.ok) {
    // Throwing here is correct — the caller can catch this
    throw new Error(`Failed to fetch post: ${res.status}`);
  }

  // TypeScript knows post is Post because we told it
  const post: Post = await res.json();
  return post.title; // string — not string | undefined
}

Typed fetch Wrapper

The built-in fetch returns Promise<Response> and response.json() returns Promise<any>, which throws away every type benefit you’ve built up. A small generic wrapper fixes this once and gives you typed responses everywhere — you call it with a type parameter and get that type back, so errors from mismatched shapes surface at compile time instead of at runtime in production.

async function fetchJson<T>(
  url: string,
  options?: RequestInit
): Promise<T> {
  const response = await fetch(url, options);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  // The type assertion is the only place we trust the server shape
  return response.json() as Promise<T>;
}

// Usage — fully typed responses, no any leaking out
const users = await fetchJson<User[]>("/api/users");
const post = await fetchJson<Post>(`/api/posts/${id}`);

// POST with typed body
async function createPost(data: Omit<Post, "id">): Promise<Post> {
  return fetchJson<Post>("/api/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
}

Error Handling in async/await

With strict: true (which enables useUnknownInCatchVariables), caught errors are unknown — because anything can be thrown in JavaScript, not just Error objects. This forces you to check the type before accessing properties like .message, which is the correct behavior. The Result pattern takes this further: instead of throwing at all, you return a typed discriminated union that makes both success and failure visible in the function signature.

// Basic approach: narrow the unknown error before using it
async function loadUser(id: number): Promise<User | null> {
  try {
    return await fetchJson<User>(`/api/users/${id}`);
  } catch (err) {
    if (err instanceof Error) {
      console.error("Fetch failed:", err.message);
    }
    return null;
  }
}

// Cleaner pattern: Result type makes error handling explicit at the call site
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

async function safeAsync<T>(fn: () => Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, value: await fn() };
  } catch (err) {
    return {
      ok: false,
      error: err instanceof Error ? err : new Error(String(err)),
    };
  }
}

const result = await safeAsync(() => fetchJson<User>("/api/users/1"));
if (result.ok) {
  console.log(result.value.name); // User — TypeScript knows this branch is safe
} else {
  console.error(result.error.message); // Error — TypeScript knows this branch has an error
}

Promise Combinators

Running async operations sequentially when they could run in parallel is a common performance mistake. JavaScript’s Promise combinators solve this, and TypeScript types all of them correctly — Promise.all even infers the tuple type of the results, so each variable gets its own specific type rather than a union of all possibilities.

// Promise.all — runs all requests in parallel, fails fast if any reject
// TypeScript infers: [User, Post[], Comment[]] — not Array<User | Post[] | Comment[]>
const [user, posts, comments] = await Promise.all([
  fetchJson<User>(`/api/users/${id}`),
  fetchJson<Post[]>(`/api/users/${id}/posts`),
  fetchJson<Comment[]>(`/api/users/${id}/comments`),
]);
// user: User, posts: Post[], comments: Comment[]

// Promise.allSettled — waits for all, never throws, gives you individual results
const results = await Promise.allSettled([
  fetchJson<User>("/api/users/1"),
  fetchJson<User>("/api/users/999"), // might 404
]);

results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log("Got user:", result.value.name);
  } else {
    console.error("Failed:", result.reason);
  }
});

// Promise.race — useful for implementing timeouts
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

// Promise.any — resolves with first fulfilled, useful for redundant requests
const fastestMirror = await Promise.any([
  fetchJson("/mirror1/api"),
  fetchJson("/mirror2/api"),
  fetchJson("/mirror3/api"),
]);

Typed AbortController

Long-running requests need a way to be cancelled — when a component unmounts, when the user navigates away, or when a newer request supersedes an older one. The AbortController API handles this, and TypeScript’s AbortSignal type integrates directly with fetch’s options so the wiring is fully type-checked.

async function fetchWithAbort<T>(
  url: string,
  signal: AbortSignal
): Promise<T> {
  const response = await fetch(url, { signal }); // AbortSignal passed to fetch
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json() as Promise<T>;
}

// React-style usage: create a controller, cancel on cleanup
function useUser(id: number) {
  const controller = new AbortController();

  const promise = fetchWithAbort<User>(`/api/users/${id}`, controller.signal);

  // In a useEffect cleanup or component unmount:
  // controller.abort();

  return { promise, cancel: () => controller.abort() };
}

Async Iterators

When an API returns paginated data, you typically write clunky loops that manually track page numbers and stopping conditions. Async generators let you hide that plumbing behind a clean for await...of loop. TypeScript types async iterables with AsyncGenerator<T>, so every yielded value is typed and the consumer never sees the pagination mechanics.

// The generator handles pagination internally — the caller just iterates
async function* paginate<T>(
  fetchPage: (page: number) => Promise<{ data: T[]; hasMore: boolean }>
): AsyncGenerator<T> {
  let page = 1;
  while (true) {
    const { data, hasMore } = await fetchPage(page++);
    for (const item of data) {
      yield item; // TypeScript knows item is T
    }
    if (!hasMore) break;
  }
}

// Clean consumer — no page tracking, no manual loop management
for await (const user of paginate((page) =>
  fetchJson(`/api/users?page=${page}`)
)) {
  console.log(user.name);
}

Concurrent Operations with Typed Results

A common real-world pattern is running several independent async operations at once and reporting the outcome of each, even if some fail. The combination of Promise.all and the Result type handles this cleanly — every task runs in parallel, failures are captured as values rather than exceptions, and the result array gives you per-task status with full type information.

interface TaskResult<T> {
  name: string;
  result: Result<T>;
}

async function runConcurrently<T>(
  tasks: Array<{ name: string; fn: () => Promise<T> }>
): Promise<TaskResult<T>[]> {
  return Promise.all(
    tasks.map(async ({ name, fn }) => ({
      name,
      result: await safeAsync(fn), // captures failures without throwing
    }))
  );
}

const results = await runConcurrently([
  { name: "users", fn: () => fetchJson<User[]>("/api/users") },
  { name: "posts", fn: () => fetchJson<Post[]>("/api/posts") },
  { name: "config", fn: () => fetchJson<Config>("/api/config") },
]);

results.forEach(({ name, result }) => {
  if (result.ok) {
    console.log(`${name}: loaded ${(result.value as any[]).length ?? 1} items`);
  } else {
    console.error(`${name}: ${result.error.message}`);
  }
});

Typed Event-Driven Async

Sometimes you need to wait for a one-time event — a user action, a WebSocket message, a resize — and then continue. Wrapping the event listener in a Promise makes this composable with async/await. TypeScript’s WindowEventMap ensures the returned event type matches the event name, so you get a MouseEvent when you listen for "click" and a KeyboardEvent for "keydown".

// The return type is inferred from the event map — no manual typing needed
function waitForEvent<K extends keyof WindowEventMap>(
  target: Window,
  event: K,
  options?: AddEventListenerOptions
): Promise<WindowEventMap[K]> {
  return new Promise((resolve) => {
    target.addEventListener(
      event,
      (e) => resolve(e),
      { ...options, once: true } // automatically removes listener after firing
    );
  });
}

// TypeScript knows click is MouseEvent — .clientX and .clientY are available
const click = await waitForEvent(window, "click");
console.log(`Clicked at ${click.clientX}, ${click.clientY}`);

Queue and Rate Limiting

When you need to fire many async operations but want to limit how many run simultaneously — to avoid overwhelming an API, a database, or a thread pool — a concurrency queue is the right tool. The queue accepts tasks and runs them up to the specified concurrency limit, queuing the rest until a slot opens up.

class AsyncQueue<T> {
  private queue: Array<() => Promise<T>> = [];
  private running = 0;

  constructor(private readonly concurrency: number) {}

  add(task: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      // Wrap the task so its resolution/rejection propagates to the caller
      this.queue.push(() => task().then(resolve, reject));
      this.run();
    });
  }

  private run(): void {
    // Start as many tasks as allowed by the concurrency limit
    while (this.running < this.concurrency && this.queue.length > 0) {
      const task = this.queue.shift()!;
      this.running++;
      task().finally(() => {
        this.running--;
        this.run(); // check if more tasks can start
      });
    }
  }
}

const queue = new AsyncQueue<User>(3); // max 3 concurrent requests

// All requests are submitted at once but only 3 run at a time
const users = await Promise.all(
  userIds.map((id) =>
    queue.add(() => fetchJson<User>(`/api/users/${id}`))
  )
);

Frequently Asked Questions

How do I type an async function?
An async function always returns a Promise. The return type annotation is Promise<T> where T is the resolved value type. TypeScript infers this automatically, but explicit annotations are useful for documenting public APIs.
How do I handle errors in async/await?
Wrap await calls in try/catch. With strict mode, the caught error is unknown — check with instanceof Error before accessing .message. Alternatively, wrap async operations to return Result types instead of throwing.
How do I type the fetch API?
fetch returns Promise<Response>. The response.json() method returns Promise<any>. Use a generic wrapper function and type assertion (or a runtime validator) to get a typed result.