TypeScript Async Patterns

A developer guide to async/await, Promise types, error handling, and concurrency patterns in TypeScript.

TL;DR

  1. Annotate asynchronous function returns explicitly with the generic Promise<T> type.
  2. Narrow caught error instances with instanceof Error before reading properties.
  3. Execute parallel asynchronous operations safely using the Promise.all utility.

Async Function Signatures

    Promise return type

    Wraps the resolved value type inside a generic Promise.

    async function fetchUser(id: string): Promise<User> {
      const res = await fetch(`/api/users/${id}`);
      return res.json();
    }
    async arrow function

    Applies asynchronous execution to arrow expressions.

    const loadData = async (): Promise<number[]> => {
      return [10, 20, 30];
    };
    top-level await

    Awaits promises directly at the module root level.

    // Allowed in ES modules
    const config = await loadRemoteConfig();
    console.log(config.endpoint);

Error Handling Patterns

    try/catch with unknown

    Catches unknown exceptions and narrows using instanceof.

    try {
      await riskyOperation();
    } catch (err: unknown) {
      if (err instanceof Error) console.error(err.message);
    }
    custom error guard

    Validates application-specific error structures safely.

    interface ApiError { code: number; message: string; }
    function isApiError(e: unknown): e is ApiError {
      const isObj = typeof e === 'object' && e !== null;
      return isObj && 'code' in e;
    }
    result tuple pattern

    Returns tuples resembling Go-style error handling.

    async function safeRun<T>(
      p: Promise<T>
    ): Promise<[T | null, Error | null]> {
      try {
        return [await p, null];
      } catch (e) {
        return [null, e as Error];
      }
    }

Concurrent Operations

    Promise.all

    Runs multiple independent tasks in parallel.

    const [user, orders] = await Promise.all([
      fetchUser('u1'),
      fetchOrders('u1')
    ]);
    Promise.allSettled

    Waits for all tasks without failing on single rejections.

    const results = await Promise.allSettled([
      syncAnalytics(),
      syncProfile()
    ]);
    for (const r of results) {
      if (r.status === 'fulfilled') console.log(r.value);
    }
    Promise.race

    Resolves or rejects as soon as the fastest Promise completes.

    const data = await Promise.race([
      fetchData(),
      timeout(5000)
    ]);

Sequential Iteration

    for...of await loop

    Executes asynchronous iterations sequentially in order.

    for (const url of urls) {
      const res = await fetch(url);
      console.log(await res.text());
    }
    for await...of loop

    Consumes asynchronous iterable streams item by item.

    async function streamData(
      stream: AsyncIterable<string>
    ) {
      for await (const chunk of stream) {
        console.log(chunk);
      }
    }
    sequential reduce

    Chains asynchronous operations sequentially with reduce.

    const final = await tasks.reduce(
      async (prev, nextTask) => {
        await prev;
        return nextTask();
      },
      Promise.resolve()
    );

Cancellation Patterns

    AbortController signal

    Propagates cancellation signals into async operations.

    const controller = new AbortController();
    const res = await fetch('/api/data', {
      signal: controller.signal
    });
    // controller.abort(); // cancels in-flight request
    timeout with AbortSignal

    Automatically aborts network operations after deadlines.

    const timeoutSignal = AbortSignal.timeout(3000);
    const response = await fetch('/api/heavy', {
      signal: timeoutSignal
    });
    checking signal aborted

    Checks cancellation state in long-running loops.

    function runWork(signal: AbortSignal) {
      if (signal.aborted) throw new Error('Aborted');
    }

Tips

  1. Type caught error variables as unknown and use type guards before reading .message or .stack attributes.
  2. Use Promise.allSettled() instead of Promise.all() when individual task rejections should not abort the entire batch.

Warnings

  1. Never use async callbacks directly inside array .forEach() loops because promises are not awaited sequentially.
  2. Forgetting to await a rejected Promise inside try/catch causes unhandled rejections that crash Node.js processes.

In Practice

FAQ