Typing async functions and Promises in TypeScript

1. Overview

The async function appears in nearly all modern TypeScript code: calling APIs, reading databases, processing queues. Writing an async function is easy. Declaring types for it so the compiler catches errors is harder.

This article covers how to type async functions and Promises. The main topics include Promise<T>, Awaited<T>, types for Promise.all and Promise.allSettled, how to handle errors with types, and generic helpers like timeout. After reading, you’ll avoid subtle errors like forgetting await or using async in forEach.

2. Setting up the environment

The examples use TypeScript in strict mode and Vitest for testing:

npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext

The --strict flag enables useUnknownInCatchVariables. This flag is important for the error handling section in part 5.

All examples share a simple model and a function that simulates network delay:

// models.ts
export interface Order {
  id: string;
  customerId: string;
  total: number;
  status: 'PENDING' | 'PAID';
}

export interface Customer {
  id: string;
  name: string;
}

export const delay = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

delay returns Promise<void>. This is the type of a Promise that just signals “done” without carrying a value. Here you must explicitly write new Promise<void>. Without it, TypeScript infers Promise<unknown>.

3. The return type of async functions

3.1. Always Promise<T>

An async function always returns a Promise. The value you return inside will be wrapped in it:

// order-service.ts
import { Order, Customer, delay } from './models';

const orders = new Map<string, Order>([
  ['o-1', { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PENDING' }],
]);

export async function fetchOrder(id: string): Promise<Order> {
  await delay(50);
  const order = orders.get(id);
  if (!order) {
    throw new Error(`Order ${id} not found`);
  }
  return order; // type Order, wrapped into Promise<Order>
}

export async function fetchCustomer(id: string): Promise<Customer> {
  await delay(30);
  return { id, name: 'Nguyễn Văn An' };
}

The type declaration is Promise<Order>, not Order. If you write async function fetchOrder(id: string): Order, the compiler reports an error:

error TS1064: The return type of an async function or method must be the global Promise<T> type.

The throw statement inside an async function doesn’t change the return type. The error is converted to a rejected Promise. The caller catches it with try/catch around await, or with .catch().

3.2. Inference or explicit declaration?

TypeScript infers the return type if you omit the annotation. However, for public functions you should declare the type explicitly. Consider this example:

async function findOrder(id: string) {
  const order = orders.get(id);
  if (!order) return null;
  return order;
}
// Inferred type: Promise<Order | null>

This type is correct, but it depends on the function body. Suppose someone changes the first branch to return undefined. The type will silently change to Promise<Order | undefined>, and every call checking === null will be wrong with no warning. When you explicitly write Promise<Order | null>, the compiler reports the error right at the function.

4. Extracting the inner type of a Promise with Awaited<T>

Sometimes you have an async function and need the type of the value after await. ReturnType isn’t enough, because it returns the Promise itself:

import { fetchOrder } from './order-service';

type R1 = ReturnType<typeof fetchOrder>;          // Promise<Order>
type R2 = Awaited<ReturnType<typeof fetchOrder>>; // Order

type Nested = Awaited<Promise<Promise<number>>>;  // number

Awaited<T> correctly simulates the behavior of await. It “unwraps” Promises recursively, even when Promises are nested. This type is very useful when you write generics, or when a library doesn’t export the result type:

type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }

5. Error types: Promise has no error parameter

Promise<T> has one type parameter, for the value on success. There is no type syntax like Promise<Order, NotFoundError>. Therefore the variable in catch has type unknown:

async function printOrder(id: string): Promise<void> {
  try {
    const order = await fetchOrder(id);
    console.log(order.total);
  } catch (err) {
    // err: unknown
    if (err instanceof Error) {
      console.error('Error:', err.message);
    } else {
      console.error('Unknown error:', err);
    }
  }
}

await printOrder('o-404');
Error: Order o-404 not found

JavaScript allows throw to throw any value: strings, numbers, objects. Therefore unknown is the only truthful type for err. You must narrow the type with instanceof before accessing err.message.

If you want errors to be part of the type, you can use the Result pattern:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

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

const result = await attempt(fetchOrder('o-1'));
if (result.ok) {
  console.log(result.value.status); // result.value: Order
} else {
  console.log(result.error.message);
}
PENDING

The ok property is a discriminant. After checking if (result.ok), TypeScript knows for sure that result.value exists and has type Order. The caller cannot “forget” to handle the error, because getting value requires checking ok first.

Note: Don’t wrap every function with Result. This pattern suits expected business errors, like “not found” or “out of stock”. For infrastructure errors like lost DB connection, let the exception bubble up to the common error handler.

6. Types for Promise.all, allSettled, and race

6.1. Promise.all preserves tuple types

When passing an array literal, TypeScript infers the type for each position:

const [order, customer] = await Promise.all([
  fetchOrder('o-1'),
  fetchCustomer('c-1'),
]);
// order: Order, customer: Customer

console.log(`${customer.name} - ${order.total}`);
Nguyễn Văn An - 250000

Both requests run in parallel. Total time is around 50ms, not 80ms. The result type is [Order, Customer], not (Order | Customer)[]. Thanks to that, you can destructure without needing to cast.

This is only true when you pass the array directly. If you create the array first and then pass it, the tuple type is lost:

const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[]  -- tuple information lost

const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer

Adding as const helps preserve the tuple type.

6.2. Promise.allSettled and type narrowing

Promise.allSettled doesn’t reject. Each element in the result has type PromiseSettledResult<T>:

const settled = await Promise.allSettled([
  fetchOrder('o-1'),
  fetchOrder('o-404'),
]);

for (const r of settled) {
  if (r.status === 'fulfilled') {
    console.log('OK', r.value.id);    // r.value: Order
  } else {
    console.log('FAIL', r.reason);    // r.reason: any
  }
}
OK o-1
FAIL Error: Order o-404 not found

r.reason has type any, not unknown. This is a small gap in the standard library, so you still need to check instanceof Error before using it.

6.3. Promise.race and generic timeout helper

Promise.race returns the union type of the Promises. Combined with Promise<never>, we have a timeout helper that preserves the type:

export 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]);
}

const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order

Since timeout has type Promise<never>, the result of race is Promise<T | never>, which is Promise<T>. never disappears from the union type. This is exactly its meaning: “this branch never returns a value”.

Note: The example above doesn’t cancel the setTimeout when promise completes first. In production code, you should save the timer id and call clearTimeout in finally. Alternatively, use AbortSignal.timeout(ms) if the underlying API supports signal.

7. Typing async callbacks

7.1. The () => void trap

TypeScript allows assigning a function that returns Promise<void> to type () => void. This looks harmless but can hide errors:

function onOrderPaid(handler: (order: Order) => void) {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  handler(order); // Promise is ignored
  console.log('Handler called');
}

onOrderPaid(async (order) => {
  await delay(10);
  throw new Error('Failed to send email'); // unhandled rejection!
});
Handler called
Error: Failed to send email   (unhandled rejection, can crash Node process)

onOrderPaid doesn’t know the handler is async. It doesn’t await, so the error is not caught. The fix is to declare clearly that the handler can be async, then await it:

type OrderHandler = (order: Order) => void | Promise<void>;

async function onOrderPaidSafe(handler: OrderHandler): Promise<void> {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  await handler(order); // awaiting a non-Promise value is still valid
}

The type void | Promise<void> accepts both synchronous and asynchronous handlers. await on a synchronous value just returns that value.

7.2. async in forEach

This is the most common error. forEach takes (item) => void so it ignores Promises:

async function payAll(list: Order[]): Promise<void> {
  list.forEach(async (o) => {
    await delay(20);
    o.status = 'PAID';
  });
  console.log(list.map((o) => o.status)); // still ['PENDING', ...]
}

There are two fixes, depending on your needs:

// Sequentially: one at a time
for (const o of list) {
  await delay(20);
  o.status = 'PAID';
}

// In parallel: return type is Promise<void[]>
await Promise.all(
  list.map(async (o) => {
    await delay(20);
    o.status = 'PAID';
  }),
);

To automatically catch these type errors, enable the @typescript-eslint/no-floating-promises and @typescript-eslint/no-misused-promises rules in typescript-eslint. The TypeScript compiler alone won’t report these errors.

8. Testing behavior and types with Vitest

Vitest allows testing both runtime values and compile-time types:

// order-service.test.ts
import { describe, it, expect, expectTypeOf } from 'vitest';
import { fetchOrder, fetchCustomer } from './order-service';
import { withTimeout } from './with-timeout';
import { delay, type Order } from './models';

describe('order-service', () => {
  it('returns an order when it exists', async () => {
    await expect(fetchOrder('o-1')).resolves.toMatchObject({
      id: 'o-1',
      status: 'PENDING',
    });
  });

  it('rejects when order is not found', async () => {
    await expect(fetchOrder('o-404')).rejects.toThrow('Order o-404 not found');
  });

  it('withTimeout rejects when promise takes too long', async () => {
    const slow = delay(200).then(() => 'done');
    await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout after 50ms');
  });

  it('preserves correct types for Promise.all and withTimeout', () => {
    expectTypeOf(fetchOrder).returns.resolves.toEqualTypeOf<Order>();
    expectTypeOf(withTimeout(fetchOrder('o-1'), 100)).resolves.toEqualTypeOf<Order>();
    expectTypeOf(
      Promise.all([fetchOrder('o-1'), fetchCustomer('c-1')]),
    ).resolves.toMatchTypeOf<[Order, { name: string }]>();
  });
});

Run tests:

npx vitest run --typecheck
 ✓ order-service.test.ts (4 tests)
 Test Files  1 passed (1)
      Tests  4 passed (4)

There are three things to note:

  • Always await before expect(...).resolves/.rejects. If you forget, the test ends before the Promise settles and always passes.
  • .rejects.toThrow(...) checks the message of the rejected error.
  • expectTypeOf doesn’t run any logic at runtime. The --typecheck flag asks Vitest to run tsc to verify types. If someone changes withTimeout to return Promise<unknown>, this test fails.

9. Common errors

Error Consequence Fix
Forgetting await when calling async function Variable has type Promise<Order> instead of Order; errors are swallowed Enable no-floating-promises; carefully read the type on hover
if (fetchOrder(id)) Always true because Promise is an object TypeScript 5.x reports a condition that’s always true; await first
async in forEach Doesn’t wait, errors aren’t caught for...of or Promise.all(map)
Accessing err.message in catch Compilation error when strict is enabled Narrow with instanceof Error
Casting as Promise<Order> on data from fetch Lying type, errors appear at runtime Validate data with a schema before assigning a type

The last error deserves more explanation. await response.json() returns Promise<any>. Writing const order: Order = await res.json() compiles, but TypeScript checks nothing. Data from outside the process always needs validation.

Note: Not every function that returns a Promise needs the async keyword. If the function just forwards a Promise, like return repository.find(id), you can omit async. Keep async when the function has try/catch around await. If you remove async and await in that case, catch won’t catch the rejection.

10. Conclusion

An async function always returns Promise<T>. Declare this type explicitly for public APIs. Use Awaited<T> when you need the type with the Promise “unwrapped”. Promise.all preserves the tuple type if you pass an array literal or use as const. Combine Promise.race with Promise<never> to write generic helpers that don’t lose types.

For errors, Promise doesn’t carry the error type. The variable in catch is unknown, and the Result pattern is an option when you want business errors visible in the type. Finally, be careful with callbacks typed () => void and forEach. Enable ESLint rules about Promises to automatically catch errors the compiler misses.

typescriptasyncpromisegenericsvitest