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.
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.
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.
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.
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: Avoid the
asynckeyword on functions that don't useawaitinside them — they still return aPromise, which adds overhead and can hide the function's synchronous nature from callers. If a function just wraps a value or calls another async function with a directreturn, keep it synchronous or return the Promise directly without wrapping it in anotherasynclayer.
Next Steps
Async code often fetches data from APIs or user input — data you can't trust at compile time. Next, you'll learn how Zod lets you define schemas that validate data at runtime and automatically infer TypeScript types, closing the gap between compile-time safety and runtime reality.
Next lesson
Zod And Validation
Learn Zod for runtime schema validation in TypeScript. Parse untrusted data safely and infer types from schemas automatically.
25 min