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.
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
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. Because load is a const and each .then reads the Promise the line above produced, only one order runs top-to-bottom.
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");
Try It Yourself
Implement a retry wrapper that attempts an async operation up to maxAttempts times, waiting delayMs between each try. On every failure it should log which attempt failed, and if all attempts are exhausted it should throw the last error.
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.
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.
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.