In the previous tutorial, we learned about error handling patterns. Now let’s learn about async/await and Promises — how to write type-safe asynchronous code in TypeScript.
By the end of this tutorial, you will know how to type Promises, async functions, Promise.all, Promise.allSettled, error handling in async code, and how to build a typed fetch wrapper.
Promises in TypeScript
A Promise represents a value that will be available later. TypeScript adds types to Promises:
const promise: Promise<string> = new Promise((resolve) => {
setTimeout(() => {
resolve("Hello");
}, 1000);
});
promise.then((value) => {
console.log(value); // value is string
});
The generic type Promise<string> tells TypeScript what value the Promise will resolve to.
Async Functions
The async keyword makes a function return a Promise:
async function fetchGreeting(): Promise<string> {
return "Hello, TypeScript";
}
// TypeScript infers the return type as Promise<string>
async function fetchNumber() {
return 42; // Return type: Promise<number>
}
Explicit Return Types
Always add explicit return types to async functions in larger projects. It makes errors obvious:
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
// Note: response.json() returns unknown at runtime — TypeScript trusts
// the declared return type here. For real apps, validate with Zod.
return response.json() as Promise<User>;
}
Await and Type Narrowing
await unwraps a Promise to its resolved value:
async function main() {
const greeting: string = await fetchGreeting();
// greeting is string, not Promise<string>
const user: User = await getUser(1);
console.log(user.name); // TypeScript knows user has a name property
}
Promise.all — Run in Parallel
Promise.all takes an array of Promises and returns a Promise of all results:
async function loadDashboard() {
const [user, products, orders] = await Promise.all([
getUser(1), // Promise<User>
getProducts(), // Promise<Product[]>
getOrders(), // Promise<Order[]>
]);
// TypeScript infers:
// user: User
// products: Product[]
// orders: Order[]
console.log(user.name, products.length, orders.length);
}
Promise.all preserves the types of each Promise in a tuple. If any Promise rejects, the whole Promise.all rejects.
Promise.race — First One Wins
Promise.race resolves with the first Promise that settles:
async function fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number
): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Timeout")), timeoutMs);
});
return Promise.race([promise, timeout]);
}
// Usage — fails if getUser takes more than 5 seconds
const user = await fetchWithTimeout(getUser(1), 5000);
Promise.allSettled — No Failures
Promise.allSettled waits for all Promises to complete, even if some fail:
async function loadAll() {
const results = await Promise.allSettled([
getUser(1),
getUser(2),
getUser(3),
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("Success:", result.value); // result.value is User
} else {
console.log("Failed:", result.reason); // result.reason is any
}
}
}
Each result has a status of either "fulfilled" or "rejected". TypeScript narrows the type based on the status.
Awaited Utility Type
Awaited<T> unwraps the type inside a Promise:
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // number — nested unwrap
type C = Awaited<string>; // string — not a promise
// Real-world use — get the return type of an async function
async function fetchUsers(): Promise<User[]> {
const res = await fetch("/api/users");
return res.json();
}
type Users = Awaited<ReturnType<typeof fetchUsers>>; // User[]
Error Handling in Async Code
Try/Catch with Async
async function getUser(id: number): Promise<User> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
if (error instanceof Error) {
console.error("Failed to fetch user:", error.message);
}
throw error; // Re-throw to let the caller handle it
}
}
Combining with Result Type
From the previous tutorial, we can use the Result type with async functions:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function getUser(id: number): Promise<Result<User, string>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return { ok: false, error: `HTTP ${response.status}` };
}
const user: User = await response.json();
return { ok: true, value: user };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "Unknown error" };
}
}
// Usage — no try/catch needed by the caller
const result = await getUser(1);
if (result.ok) {
console.log(result.value.name);
} else {
console.log("Error:", result.error);
}
Concurrent vs Sequential
Sequential — One at a Time
async function sequential() {
const user1 = await getUser(1); // waits for this
const user2 = await getUser(2); // then this
const user3 = await getUser(3); // then this
// Total time: sum of all three
}
Concurrent — All at Once
async function concurrent() {
const [user1, user2, user3] = await Promise.all([
getUser(1), // all start at the same time
getUser(2),
getUser(3),
]);
// Total time: max of the three
}
Always use Promise.all when operations don’t depend on each other. It can be much faster.
AbortController for Cancellation
Use AbortController to cancel fetch requests:
async function fetchWithCancel(
url: string,
signal: AbortSignal
): Promise<User> {
const response = await fetch(url, { signal });
return response.json();
}
// Usage
const controller = new AbortController();
// Start the request
const promise = fetchWithCancel("/api/users/1", controller.signal);
// Cancel after 3 seconds
setTimeout(() => controller.abort(), 3000);
try {
const user = await promise;
console.log(user.name);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
console.log("Request was cancelled");
}
}
Building a Typed Fetch Wrapper
Here is a practical example that combines everything:
interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "DELETE";
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
}
type ApiResult<T> =
| { ok: true; data: T; status: number }
| { ok: false; error: string; status: number };
async function api<T>(
url: string,
options: RequestOptions = {}
): Promise<ApiResult<T>> {
try {
const hasBody = options.body !== undefined;
const response = await fetch(url, {
method: options.method ?? "GET",
headers: {
// Only set Content-Type when sending a body (not for GET requests)
...(hasBody ? { "Content-Type": "application/json" } : {}),
...options.headers,
},
body: hasBody ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
if (!response.ok) {
return {
ok: false,
error: `${response.status}: ${response.statusText}`,
status: response.status,
};
}
// Note: response.json() does not validate the shape at runtime.
// For production, parse with Zod or similar.
const data: T = await response.json();
return { ok: true, data, status: response.status };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "Unknown error",
status: 0,
};
}
}
Using the Wrapper
// GET request
const users = await api<User[]>("/api/users");
if (users.ok) {
users.data.forEach(u => console.log(u.name));
}
// POST request
const created = await api<User>("/api/users", {
method: "POST",
body: { name: "Alex", email: "alex@example.com" },
});
if (created.ok) {
console.log("Created:", created.data.id);
} else {
console.log("Failed:", created.error);
}
Typing Callback-Based APIs
Some older APIs use callbacks. You can wrap them in Promises with types:
import * as fs from "node:fs";
function readFile(path: string): Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf-8", (error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
});
});
}
Most Node.js APIs now have Promise-based versions in the fs/promises, timers/promises, and stream/promises modules.
Common Mistakes
Mistake 1: Forgetting await
// Bug — missing await, user is a Promise, not a User
async function bad() {
const user = getUser(1); // Missing await!
console.log(user.name); // Error at runtime
}
Mistake 2: Sequential When Concurrent Is Better
// Slow — waits for each one
const a = await fetchA();
const b = await fetchB();
// Fast — runs both in parallel
const [a, b] = await Promise.all([fetchA(), fetchB()]);
Mistake 3: Not Handling Rejections
Every Promise should have error handling. Unhandled rejections can terminate Node.js processes and cause silent failures in browsers:
// Bad — unhandled rejection if getUser throws
getUser(1).then(user => console.log(user));
// Good — handle the error
getUser(1)
.then(user => console.log(user))
.catch(error => console.error(error));
// Better — use async/await with try/catch
try {
const user = await getUser(1);
console.log(user);
} catch (error) {
console.error(error);
}
What’s Next?
In the next tutorial, we will learn about TypeScript with React — how to type components, props, hooks, events, and context.