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:
// ✅ 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) => UserWhy 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:
// ❌ 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.
// 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:
// 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.
// 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:
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.
// 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: AnalyticsThis is powerful, but there's a gotcha. If you use the spread operator or dynamic arrays, you lose the tuple type:
// ❌ 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: AnalyticsIn the CRM platform I worked on, we frequently loaded related data in parallel. Proper typing prevented accidental swaps:
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:
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 awaitor usingPromise.resolve()carelessly can createPromise<Promise<T>>. - Losing tuple types with Promise.all: Using spread operators or building arrays dynamically without
as constturns 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 everyasyncfunction; TypeScript enforces this automatically.Awaited<T>recursively unwraps promise-like types, useful for extracting resolved types from complex async compositions.AsyncIterable<T>andasync function*let you type streaming or paginated data safely.Promise.allwith 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.






Comments
Loading comments…