TL;DR
Master async/await in TypeScript with typed Promises, async functions, and error handling patterns for non-blocking, readable code.
Key concepts
- async await TypeScript
- TypeScript Promises
- typed async functions
- async error handling TypeScript
Async TypeScript
Most real-world applications need to do things that take time — fetching data from an API, reading a file, waiting for user input. JavaScript handles this with an event loop that lets other work continue while waiting. TypeScript layers its type system on top, making async code safer and more predictable.
In this lesson you'll go from raw Promises to clean async/await syntax, learn how TypeScript types flow through async operations, and handle errors without losing type safety.
What You'll Learn
By the end of this lesson you will write three typed async functions over an activity store — one that awaits a single load, one that runs several loads concurrently with Promise.all and sums the results, and one that catches a rejection and narrows the caught error before reading it — and you will be able to say what type await hands you at each step.
This lesson is not tied to a capstone milestone. What it is for is the seam every later lesson leans on: once data arrives from somewhere that takes time, every type in your program is wrapped in a Promise until you unwrap it, and forgetting to unwrap is the single most common way typed async code goes wrong. Getting that reflex now is what makes the API and validation work later read as ordinary code.
You arrive able to write a generic function and read the type parameter through it (Generics), and to narrow a caught unknown down to an Error before touching its message (Error Handling). Both come back immediately here: Promise<T> is a generic type, and a rejected Promise lands in a catch with exactly the unknown you already know how to narrow.
The Promise Type
A Promise<T> represents a value that will be available at some point in the future. The generic parameter T tells TypeScript what type that eventual value will be.
function fetchUserName(id: number): Promise<string> {
return new Promise((resolve, reject) => {
// Simulate a network delay
setTimeout(() => {
if (id <= 0) {
reject(new Error("Invalid user ID"));
} else {
resolve(`User_${id}`);
}
}, 300);
});
}
fetchUserName(42)
.then((name) => console.log("Got:", name))
.catch((err) => console.error("Failed:", err.message));
fetchUserName(-1)
.then((name) => console.log("Got:", name))
.catch((err) => console.error("Failed:", err.message));
resolve narrows the Promise to Promise<string> — TypeScript infers this from the type annotation on the function. Calling .then(name => ...) means name is already typed as string with no extra work.
Async / Await
Writing .then().catch() chains gets messy fast. The async and await keywords let you write asynchronous code that reads like synchronous code.
- Mark a function with
asyncand its return type automatically becomes aPromise - Use
awaitinside an async function to pause execution until a Promise resolves - Wrap in
try/catchto handle rejections
interface WeatherReport {
city: string;
tempC: number;
condition: string;
}
async function getWeather(city: string): Promise<WeatherReport> {
// Simulate an API call
await new Promise((resolve) => setTimeout(resolve, 200));
if (city === "") {
throw new Error("City name cannot be empty");
}
return {
city,
tempC: Math.round(Math.random() * 30),
condition: ["Sunny", "Cloudy", "Rainy"][Math.floor(Math.random() * 3)],
};
}
async function displayWeather(): Promise<void> {
try {
const report = await getWeather("Amsterdam");
console.log(`${report.city}: ${report.tempC}°C, ${report.condition}`);
} catch (err) {
if (err instanceof Error) {
console.error("Could not load weather:", err.message);
}
}
}
displayWeather();
Notice that getWeather is typed as returning Promise<WeatherReport>, but inside the async function you just return a plain WeatherReport object — TypeScript wraps it automatically.
Predict
An async function runs synchronously up to its first await, then the rest is scheduled to resume LATER, as a microtask. Trace the four logs by hand: run() is called between start and end. In what order do the four lines print?
async function run(): Promise<void> {
console.log("A");
await Promise.resolve();
console.log("B");
}
console.log("start");
run();
console.log("end");Typed Error Handling
TypeScript treats caught errors as unknown by default (with useUnknownInCatchVariables, enabled in strict mode). This forces you to narrow the type before accessing properties — which is exactly what you want.
class ApiError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = "ApiError";
}
}
async function loadProduct(id: number): Promise<{ id: number; name: string }> {
await new Promise((resolve) => setTimeout(resolve, 100));
if (id === 404) {
throw new ApiError(404, "Product not found");
}
if (id === 500) {
throw new ApiError(500, "Internal server error");
}
return { id, name: `Product #${id}` };
}
async function run(): Promise<void> {
for (const id of [1, 404, 500]) {
try {
const product = await loadProduct(id);
console.log(`Loaded: ${product.name}`);
} catch (err) {
if (err instanceof ApiError) {
console.error(`[${err.statusCode}] ${err.message}`);
} else if (err instanceof Error) {
console.error(`Unexpected error: ${err.message}`);
}
}
}
}
run();
Using a custom error class gives you typed properties like statusCode that plain Error doesn't have — and the instanceof check satisfies TypeScript's type narrower.
Recall
Without scrolling up: the catch (err) above tests if (err instanceof ApiError) before reading err.statusCode. You already learned in *Error Handling* why that narrowing is mandatory. In strict mode, what is the STATIC type of err in a catch clause, and why does await not change that?
Running Promises in Parallel
The choice here is not stylistic and it is worth making deliberately. Awaiting in a loop starts each operation only after the previous one has finished, so three 100ms loads take about 300ms; handing the whole array to Promise.all starts them together and takes about as long as the slowest one. Use the loop only when a later request genuinely needs the earlier result. Use Promise.all when the operations are independent, and reach for the loop only when the dependency is real — the note below on what happens when one of them rejects is the other half of that decision.
await pauses execution — if you await multiple Promises one after another, you lose concurrency. Use Promise.all to run them in parallel and wait for all to finish.
async function fetchScore(player: string): Promise<number> {
await new Promise((resolve) => setTimeout(resolve, Math.random() * 200 + 50));
return Math.floor(Math.random() * 100);
}
async function getLeaderboard(players: string[]): Promise<void> {
// Sequential — each waits for the previous
console.time("sequential");
for (const player of players) {
const score = await fetchScore(player);
console.log(`${player}: ${score}`);
}
console.timeEnd("sequential");
// Parallel — all run at once
console.time("parallel");
const scores = await Promise.all(players.map(fetchScore));
players.forEach((player, i) => console.log(`${player}: ${scores[i]}`));
console.timeEnd("parallel");
}
getLeaderboard(["Alice", "Bob", "Carol", "Dave"]);
Promise.all returns Promise<number[]> here — TypeScript infers the tuple/array type from what you pass in. If you pass an array of Promise<string>, you get back string[].
When one Promise in
Promise.allrejects, the entire call rejects. UsePromise.allSettledwhen you want results from all Promises regardless of failures — it returns an array of{ status: 'fulfilled', value }or{ status: 'rejected', reason }objects.
Arrange the code
Reassemble a program that loads a number from a load helper (a const arrow returning a Promise), doubles the resolved value with .then, logs it with another .then, and attaches a .catch. The lines are shuffled; put them back so the program runs and logs minutes x2: 60. Then answer why this order: which line could not have been moved, and what does the runner do if you move it?
const load = (id: string): Promise<number> => Promise.resolve(id === "a1" ? 30 : 0);const doubled = pending.then((n) => n * 2);logged.catch((e) => console.error(e));const logged = doubled.then((n) => console.log("minutes x2:", n));const pending = load("a1");
A Worked Retry Wrapper
Before you build one yourself, read a finished one. This retry wrapper attempts an async operation up to maxAttempts times, waiting delayMs between each try. On every failure it logs which attempt failed, and if all attempts are exhausted it throws the last error. Read it for the shape of the loop and for where the await sits; you will write a deterministic cousin of it in the build below.
async function unstableOperation(): Promise<string> {
await new Promise((resolve) => setTimeout(resolve, 100));
if (Math.random() < 0.7) {
throw new Error("Flaky failure");
}
return "Success!";
}
async function withRetry<T>(
operation: () => Promise<T>,
maxAttempts: number,
delayMs: number
): Promise<T> {
let lastError: Error = new Error("No attempts made");
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await operation();
console.log(`Succeeded on attempt ${attempt}`);
return result;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
console.log(`Attempt ${attempt} failed: ${lastError.message}`);
if (attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
async function main(): Promise<void> {
try {
const result = await withRetry(unstableOperation, 5, 150);
console.log("Final result:", result);
} catch (err) {
if (err instanceof Error) {
console.error("All attempts failed:", err.message);
}
}
}
main();
Run it a few times — because unstableOperation fails 70% of the time, you'll see different numbers of retries. The generic <T> on withRetry means it works with any async operation returning any type, and TypeScript tracks that type all the way through.
When the await goes missing
The most common failure in typed async code is not a hard crash — it is forgetting to unwrap a Promise and then reading a property off the wrapper. The block below is that bug, and it is worth seeing what each lane makes of it.
Debug
totalMinutes should load three activities concurrently and add up their minutes. It runs to completion and then fails its own check with a value that is not a number. Commit a hypothesis about what the reduce is actually adding up — what is in the activities array at that moment — then fix it so the total comes out as 75.
import assert from "node:assert";
interface Activity {
id: string;
title: string;
minutesSpent: number;
}
const store: Record<string, Activity> = {
a1: { id: "a1", title: "Types", minutesSpent: 30 },
a2: { id: "a2", title: "Generics", minutesSpent: 20 },
a3: { id: "a3", title: "Async", minutesSpent: 25 },
};
function loadActivity(id: string): Promise<Activity> {
return Promise.resolve(store[id]);
}
// Intent: load every id concurrently and sum the minutes.
async function totalMinutes(ids: string[]): Promise<number> {
const activities = ids.map(loadActivity);
return activities.reduce((sum, a) => sum + a.minutesSpent, 0);
}
async function main(): Promise<void> {
const total = await totalMinutes(["a1", "a2", "a3"]);
assert.strictEqual(total, 75, "the three activities should total 75 minutes, got " + total);
console.log("Total minutes:", total);
}
main();Expected output:
Total minutes: 75
Try It Yourself
The retry example above is deliberately random so you can watch it vary. The build below is the opposite — fully deterministic — so it can check its own output. This is a build task: a small program that reports its own pass/fail. You finish three typed-async functions over a small activity store: one await, one Promise.all, and one that catches a rejection. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
The pieces reuse exactly what this lesson taught: await-ing a Promise<Activity> and reading the resolved value, Promise.all to load many concurrently and preserve their types, and a try/catch whose caught error is unknown and gets narrowed with instanceof Error. The store resolves and rejects deterministically — no randomness, no wall-clock delays — so the output is stable. The starter has the store, the stubs, and the checks — you write only the logic inside each function.
Build
Finish the build. Three async functions are stubbed, and the checks below them fail until each resolves to the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement them until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
interface Activity {
id: string;
title: string;
minutesSpent: number;
}
// A fake store: resolves an Activity by id, or rejects if it is missing.
// Deterministic — no random, no real delay. Do NOT change this.
const DB: Record<string, Activity> = {
a1: { id: "a1", title: "Types", minutesSpent: 30 },
a2: { id: "a2", title: "Narrowing", minutesSpent: 20 },
a3: { id: "a3", title: "Async", minutesSpent: 25 },
};
function loadActivity(id: string): Promise<Activity> {
return new Promise((resolve, reject) => {
const found = DB[id];
if (found) resolve(found);
else reject(new Error(`no activity: ${id}`));
});
}
// TODO 1: load ONE activity by id and return its minutesSpent. loadActivity
// returns a Promise<Activity>, so await it, then read .minutesSpent.
// (The function is already async, so it returns a Promise<number>.)
// await minutesFor("a1") -> 30
async function minutesFor(id: string): Promise<number> {
// your code here
return 0; // replace this
}
// TODO 2: load MANY activities CONCURRENTLY and total their minutes. Map the ids
// to loadActivity calls, await them all together with Promise.all (not one-by-one
// in a loop), then sum the minutesSpent.
// await totalMinutes(["a1", "a2", "a3"]) -> 75
async function totalMinutes(ids: string[]): Promise<number> {
// your code here
return 0; // replace this
}
// TODO 3: load an activity that MIGHT be missing, catching the rejection and
// returning the fallback instead of letting the error escape. The caught error
// is unknown — narrow with instanceof Error before doing anything with it.
// await titleOr("a2", "missing") -> "Narrowing"; await titleOr("zz", "missing") -> "missing"
async function titleOr(id: string, fallback: string): Promise<string> {
// your code here
return fallback; // replace this
}
// --- Build checks: these must all pass. Do not edit below this line. ---
async function main(): Promise<void> {
assert.strictEqual(await minutesFor("a1"), 30, "TODO 1: minutesFor should await the activity and return its minutesSpent");
assert.strictEqual(await totalMinutes(["a1", "a2", "a3"]), 75, "TODO 2: totalMinutes should await all loads and sum the minutes");
assert.strictEqual(await titleOr("a2", "missing"), "Narrowing", "TODO 3: titleOr should return the loaded activity's title when it exists");
assert.strictEqual(await titleOr("zz", "missing"), "missing", "TODO 3: titleOr should return the fallback when the load rejects");
console.log("All checks passed.");
console.log("Minutes for a1:", await minutesFor("a1"));
console.log("Total minutes:", await totalMinutes(["a1", "a2", "a3"]));
console.log("Title or fallback:", await titleOr("zz", "missing"));
}
main();Expected output:
All checks passed. Minutes for a1: 30 Total minutes: 75 Title or fallback: missing
Once it passes, try two variations and predict each before running:
- Forget
Promise.all. IntotalMinutes, changeawait Promise.all(ids.map(loadActivity))to justids.map(loadActivity)(dropping thePromise.alland theawait). Predict what fails first — in BOTH lanes. Under the runner,activitiesis now an array of Promises, soa.minutesSpentreads a missing property on a Promise object —undefined— and the sum becomesNaN, firing TODO 2's check withAssertionError: TODO 2: totalMinutes should await all loads and sum the minutesandNaN !== 75. Andtsc --strictwould have caught it before it ran:error TS2339: Property 'minutesSpent' does not exist on type 'Promise<Activity>'.— a forgotten await turned into a type error the runner alone would only surface as NaN. - Total a subset. After the checks pass, add
console.log("Subset total:", await totalMinutes(["a1", "a3"]));insidemainbelow the existing logs. Predict the new line before running.Promise.allloads onlya1(30) anda3(25) and sums them, so you getSubset total: 55. This changes the echoed output, not any check, and shows the same concurrent-load-and-sum working over any id list.
Transfer
In TypeScript, an async function hands back a Promise<T> and await is what turns it into a T you can read. Rust has the same split: an async fn returns a Future<Output = T>, and .await is what produces the T. In both languages, forgetting the unwrapping step leaves you holding the wrapper. Which statement names what genuinely transfers, rather than a surface resemblance?
Key Takeaways
Promise<T>carries its resolved type as a generic parameter — TypeScript knows what.then()will receiveasyncfunctions always return aPromise; you canreturna plain value and TypeScript wraps itawaitunwraps aPromise<T>toT, making async code read like synchronous code- Caught errors are
unknownin strict mode — useinstanceofto narrow before accessing properties Promise.allruns Promises in parallel and preserves types; use it instead of sequentialawaitwhen operations are independentPromise.allSettledis safer when you need results from all Promises regardless of individual failures
Pro Tip: If a function does no asynchronous work, keep it synchronous. Making it
asyncmeans every caller has toawaitit, and the value now arrives a microtask later instead of immediately — that scheduling hop is the real cost, and it is far more expensive than the plain call it replaced. Note where the cost actually lives: it is returning aPromiseat all, not theasynckeyword. Rewritingasync function f() { return x; }asfunction f() { return Promise.resolve(x); }buys you nothing — it returns the samePromiseby a longer route, and the engine'sasyncpath is the better-optimized of the two. The win comes from not returning aPromise, not from hand-rolling one.
Next Steps
You can now write typed async code — but real projects spread that code across many files. Next, you'll learn TypeScript modules: named and default exports, type-only imports, re-exports, and barrel files — how the pieces you have been building in one file get organized into a real codebase.
Ready to continue? Head to Modules!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.