Technology · TypeScript

TypeScript Async Patterns

A quick reference for async/await, Promises, error handling, and concurrent operations in TypeScript.

TL;DR
  1. 01Use async/await to write asynchronous code that reads synchronously.
  2. 02Always wrap async calls in try/catch to handle Promise rejections.
  3. 03Use Promise.all to run independent async operations in parallel.

Async/Await Basics

  • Use async/await for readable asynchronous code.
    async function fetchUser(id: number): Promise<User> {
      const response = await fetch(`/api/users/${id}`);
      return response.json();
    }
  • Await pauses execution until the Promise resolves.
    async function loadData() {
      const data = await fetchUser(1); // waits here
      console.log(data.name);         // runs after fetch completes
    }
  • Async functions always return a Promise automatically.
    async function getValue(): Promise<number> {
      return 42; // wrapped in Promise automatically
    }
  • Chain multiple awaits for sequential dependent calls.
    async function loadProfile(userId: number) {
      const user = await fetchUser(userId);
      const posts = await fetchPosts(user.id);
      return { user, posts };
    }
  • Mark top-level module code as async when needed.
    // top-level await in modules
    const config = await loadConfig();
    console.log(config.apiUrl);

Error Handling

  • Handle errors with try/catch blocks.
    async function fetchData() {
      try {
        const data = await fetch('/api/data');
        return data.json();
      } catch (error) {
        console.error('Failed to fetch:', error);
        return null;
      }
    }
  • Catch both fetch and JSON parsing errors in one block.
    async function safeLoad(url: string) {
      try {
        const res = await fetch(url);
        const json = await res.json(); // can also throw
        return json;
      } catch (err) {
        return null;
      }
    }
  • Use a typed catch block to inspect the error shape.
    async function fetchUser(id: number) {
      try {
        return await getUser(id);
      } catch (err) {
        if (err instanceof Error) {
          console.error(err.message);
        }
      }
    }
  • Re-throw after logging to propagate to the caller.
    async function loadConfig() {
      try {
        return await fetchConfig();
      } catch (err) {
        logger.error('Config load failed', err);
        throw err; // let the caller decide
      }
    }
  • Handle HTTP errors separately from network errors.
    async function apiGet(url: string) {
      const res = await fetch(url);
      if (!res.ok) {
        throw new Error(`HTTP ${res.status}: ${res.statusText}`);
      }
      return res.json();
    }

Concurrent Operations

  • Run multiple async operations in parallel with Promise.all.
    async function loadAllData() {
      const [users, posts, comments] = await Promise.all([
        fetchUsers(),
        fetchPosts(),
        fetchComments()
      ]);
      return { users, posts, comments };
    }
  • Promise.all is faster than sequential awaits for independent calls.
    // Slow: sequential (waits each time)
    const user = await fetchUser(1);
    const posts = await fetchPosts(1);
    
    // Fast: parallel (runs at same time)
    const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]);
  • Use Promise.allSettled to collect all results even on failure.
    const results = await Promise.allSettled([
      fetchUser(1),
      fetchUser(2),
      fetchUser(3)
    ]);
    
    results.forEach(r => {
      if (r.status === "fulfilled") console.log(r.value);
      else console.error(r.reason);
    });
  • Use Promise.race to get the first resolved result.
    const fastest = await Promise.race([
      fetchFromRegion("us"),
      fetchFromRegion("eu")
    ]);
  • Use Promise.any to get first fulfilled (ignores rejections).
    const first = await Promise.any([
      fetchMirror1(),
      fetchMirror2(),
      fetchMirror3()
    ]);

Sequential Operations

  • Run operations in sequence when the result depends on the previous call.
    async function getPostWithAuthor(postId: number) {
      const post = await fetchPost(postId);
      const author = await fetchUser(post.userId);
      return { post, author };
    }
  • Await each operation in order to preserve dependency.
    async function createAndNotify(data: NewUser) {
      const user = await createUser(data);
      const token = await generateToken(user.id);
      await sendWelcomeEmail(user.email, token);
      return user;
    }
  • Loop with await to process items one at a time.
    async function processQueue(ids: number[]) {
      for (const id of ids) {
        await processItem(id); // each waits before next
      }
    }
  • Use reduce to accumulate sequential async results.
    async function runSteps(steps: Array<() => Promise<string>>) {
      return steps.reduce(async (prev, step) => {
        const results = await prev;
        const result = await step();
        return [...results, result];
      }, Promise.resolve<string[]>([]));
    }
  • Use for-await-of to iterate async iterables.
    async function readStream(stream: AsyncIterable<string>) {
      for await (const chunk of stream) {
        console.log(chunk);
      }
    }

Type-Safe Async Operations

  • Type Promise return values explicitly for caller safety.
    async function getData(): Promise<{ id: number; name: string }> {
      const response = await fetch('/api/data');
      return response.json();
    }
  • Use generics to build reusable typed fetch helpers.
    async function fetchData<T>(url: string): Promise<T> {
      const response = await fetch(url);
      return response.json() as T;
    }
    
    const user = await fetchData<User>('/api/user/1');
  • Type async callbacks passed as arguments.
    async function withRetry<T>(
      fn: () => Promise<T>,
      retries: number
    ): Promise<T> {
      for (let i = 0; i <= retries; i++) {
        try { return await fn(); } catch (e) {
          if (i === retries) throw e;
        }
      }
      throw new Error("unreachable");
    }
  • Use Result types to encode success and failure explicitly.
    type Result<T> = { ok: true; value: T } | { ok: false; error: string };
    
    async function safeFetch(url: string): Promise<Result<unknown>> {
      try {
        const data = await (await fetch(url)).json();
        return { ok: true, value: data };
      } catch (e) {
        return { ok: false, error: String(e) };
      }
    }
  • Annotate async generator functions with their yield types.
    async function* paginate<T>(
      load: (page: number) => Promise<T[]>
    ): AsyncGenerator<T> {
      let page = 0;
      while (true) {
        const items = await load(page++);
        if (!items.length) break;
        for (const item of items) yield item;
      }
    }

Tip: Use Promise.all for independent operations and sequential awaits for dependent ones — it makes a huge performance difference.

Warning: Always handle errors in async functions — unhandled rejections can crash your application.

TypeScript Advanced TypesTypeScript Declaration Files