Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 068 / 100

Typing async code for JavaScript & TypeScript developers

Promises, async iterators and Awaited.

Expona Admin9 min read

Typing async code

Asynchronous code is everywhere in modern applications—API calls, database queries, file I/O—but TypeScript's type system can feel slippery when promises are involved. Getting async typing right means the difference between catching race conditions at compile time and debugging them in production. In this post, we'll explore how to properly type async code using Promise<T>, Awaited<T>, async iterators, and promise combinators.

Promise<T> — Async functions always return promises

This is the foundation: when you declare a function as async, TypeScript automatically wraps its return type in a Promise. If you write async function fetchUser(): User, TypeScript silently transforms that to async function fetchUser(): Promise<User>. This is convenient, but it's also a source of confusion when you forget what's actually being returned.

Consider a real scenario from an e-commerce platform I built. You have a user service that fetches customer data:

typescript
// ✅ Correct: explicit Promise return type
async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return data as User;
}

// The type of getUser is: (id: string) => Promise<User>
// NOT (id: string) => User

Why does this matter? Because when you call getUser(), you need to await it or chain .then(). If your types suggest it returns User directly but your code returns a promise, you're headed for runtime errors.

Here's where mistakes happen. You might see code like this in a controller or handler:

typescript
// ❌ Wrong: forgetting to await
function renderUserPage(userId: string): User {
  return getUser(userId); // TypeScript error: Promise<User> is not assignable to User
}

// ✅ Correct: use async or explicit Promise type
async function renderUserPage(userId: string): Promise<User> {
  return getUser(userId);
}

// ✅ Also correct: explicit .then() chaining
function renderUserPage(userId: string): Promise<User> {
  return getUser(userId).then(user => user);
}

The key insight: if your function does any await internally, it must return a Promise. TypeScript enforces this automatically when you use the async keyword, but you can also return Promise explicitly.

Awaited<T> — Unwraps nested promises in types

Now imagine a messier scenario. You're working with higher-order functions or wrapping async operations, and you end up with deeply nested promise types like Promise<Promise<User>>. This shouldn't happen in practice, but TypeScript gives you Awaited<T> to unwrap it.

Awaited<T> is a utility type that recursively unwraps promise-like types. It's been standard since TypeScript 4.5.

typescript
// Example: deeply nested promise (rare but possible)
type NestedPromise = Promise<Promise<Promise<string>>>;
type Unwrapped = Awaited<NestedPromise>; // string

// More practical example: extracting the resolved type
async function getUserWithOrders(id: string): Promise<User & { orders: Order[] }> {
  const user = await getUser(id);
  const orders = await getOrders(id);
  return { ...user, orders };
}

type UserWithOrders = Awaited<ReturnType<typeof getUserWithOrders>>;
// Expands to: User & { orders: Order[] }

In a ride-hailing app I worked on, we had a pattern where we'd compose multiple async operations and wanted to extract the final resolved type:

typescript
// Fetch ride details, driver info, and pricing in parallel
async function getRideDetails(rideId: string) {
  const [ride, driver, pricing] = await Promise.all([
    fetchRide(rideId),
    fetchDriver(rideId),
    calculatePricing(rideId)
  ]);
  return { ride, driver, pricing };
}

// Extract the type without re-writing it manually
type RideDetails = Awaited<ReturnType<typeof getRideDetails>>;
// = { ride: Ride; driver: Driver; pricing: Pricing }

This pattern is especially useful in shared type libraries where you want to expose types derived from async function return values without duplicating the type definition.

Async iterators — AsyncIterable<T> for streams and pagination

Not all async operations are one-shot calls. Sometimes you need to iterate over data that arrives asynchronously: streaming database results, paginated API responses, or real-time event feeds. That's where async iterators come in.

An AsyncIterable<T> is an object that can be consumed with for await...of. It's similar to a regular iterable, but each iteration is asynchronous.

typescript
// Define an async iterator for paginated results
async function* fetchUsersPaginated(
  pageSize: number = 10
): AsyncGenerator<User[], void, unknown> {
  let page = 1;
  while (true) {
    const response = await fetch(`/api/users?page=${page}&limit=${pageSize}`);
    const users = await response.json() as User[];
    
    if (users.length === 0) break;
    
    yield users; // Yield a batch of users
    page++;
  }
}

// Consume it
async function processAllUsers() {
  for await (const userBatch of fetchUsersPaginated(20)) {
    console.log(`Processing batch of ${userBatch.length} users`);
    // Do something with userBatch
  }
}

In a travel booking system, we used async iterators to stream flight results from multiple suppliers without loading everything into memory:

typescript
interface FlightResult {
  supplierId: string;
  flights: Flight[];
}

// Async iterator that yields results as they arrive
async function* searchFlights(
  from: string,
  to: string,
  date: string
): AsyncIterable<FlightResult> {
  const suppliers = ['supplier-a', 'supplier-b', 'supplier-c'];
  
  // Race requests and yield results as they complete
  const promises = suppliers.map(async (id) => ({
    supplierId: id,
    flights: await querySupplier(id, from, to, date)
  }));
  
  for (const promise of promises) {
    yield await promise;
  }
}

// Use it to show results to the user progressively
async function displayFlightResults(from: string, to: string, date: string) {
  for await (const { supplierId, flights } of searchFlights(from, to, date)) {
    console.log(`Results from ${supplierId}: ${flights.length} flights`);
    // Update UI incrementally
  }
}

The type system ensures you use await when iterating and that each yielded value matches your declared type. This catches bugs where you forget to await before yielding.

Promise combinators — Promise.all infers tuple types correctly

When you orchestrate multiple concurrent operations, Promise.all is your friend. TypeScript's inference here is smart: if you pass it an array of promises with different types, it correctly infers a tuple type for the result.

typescript
// Multi-type promises
const userPromise: Promise<User> = getUser('123');
const settingsPromise: Promise<Settings> = getSettings('123');
const analyticsPromise: Promise<Analytics> = getAnalytics('123');

// Promise.all with tuple inference
const [user, settings, analytics] = await Promise.all([
  userPromise,
  settingsPromise,
  analyticsPromise
]);

// Types are correctly inferred:
// user: User
// settings: Settings
// analytics: Analytics

This is powerful, but there's a gotcha. If you use the spread operator or dynamic arrays, you lose the tuple type:

typescript
// ❌ Loss of type information
const promises = [userPromise, settingsPromise, analyticsPromise];
const results = await Promise.all(promises);
// results: (User | Settings | Analytics)[]

// ✅ Keep tuple type with "as const"
const promises = [userPromise, settingsPromise, analyticsPromise] as const;
const [user, settings, analytics] = await Promise.all(promises);
// user: User, settings: Settings, analytics: Analytics

In the CRM platform I worked on, we frequently loaded related data in parallel. Proper typing prevented accidental swaps:

typescript
async function loadCompanyProfile(companyId: string) {
  const [company, contacts, deals, activities] = await Promise.all([
    fetchCompany(companyId),
    fetchContacts(companyId),
    fetchDeals(companyId),
    fetchActivities(companyId)
  ]);
  
  // Each variable has its correct type, not a union
  console.log(company.name); // ✅ OK
  console.log(contacts[0].email); // ✅ OK
  console.log(deals.map(d => d.value)); // ✅ OK
}

There's also Promise.allSettled for when you want all promises to settle (resolve or reject) before proceeding, and you need to inspect each result's status:

typescript
const results = await Promise.allSettled([
  userPromise,
  settingsPromise,
  analyticsPromise
]);

// results: PromiseSettledResult<User | Settings | Analytics>[]
results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log(result.value); // Value from resolved promise
  } else {
    console.log(result.reason); // Error from rejected promise
  }
});

Common mistakes

  • Forgetting to await: Returning a promise when you meant to return the resolved value. TypeScript usually catches this, but not if you're in a non-async function.
  • Mixing Promise and await syntax inconsistently: Some code uses await, some uses .then(), making flow hard to follow. Pick one style per function.
  • Over-nesting promises: Forgetting to flatten with return await or using Promise.resolve() carelessly can create Promise<Promise<T>>.
  • Losing tuple types with Promise.all: Using spread operators or building arrays dynamically without as const turns typed tuples into unions.
  • Not handling async iterator completion: In async generators, forgetting that the iterator completes can leave data-fetching loops running when they shouldn't.

Key takeaways

  • Promise<T> is the return type of every async function; TypeScript enforces this automatically.
  • Awaited<T> recursively unwraps promise-like types, useful for extracting resolved types from complex async compositions.
  • AsyncIterable<T> and async function* let you type streaming or paginated data safely.
  • Promise.all with tuple types ensures each concurrent operation's result has the correct type, catching accidental swaps.
  • Be explicit about whether functions return promises or resolved values; let TypeScript enforce consistency through types.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles