Async/Await in TS
await unwraps the promise, like a conveyor belt delivering your biryani token.
Async/await is one of TypeScript's most elegant features. It lets you write asynchronous code that LOOKS synchronous — no more deeply nested .then() chains, no more callback pyramids, no more confusing error handling. The key insight is simple: await takes a Promise<T> and UNWRAPS it into T. TypeScript tracks this unwrapping automatically, so your code stays type-safe without any extra effort.
Imagine a Hyderabadi biryani restaurant during lunch rush hour. You walk in, place your order, and the waiter gives you a token. That token is your Promise<Biryani> — it represents a future biryani that you will receive. Now, you have two options:
Option 1 — The Promise Chain: You stand at the counter and wait for the token to be called. When it's called, you go get the biryani, then maybe order a drink, wait for that token too, get the drink. Each step is a separate callback, a separate .then(). It works, but the code gets messy and nested.
Option 2 — The Conveyor Belt (async/await): You sit at a table, hand the waiter your token, and the biryani arrives on a conveyor belt right in front of you. You didn't have to stand and wait; you just awaited the token and the value arrived. Then you order a drink the same way — await the token, get the drink. The code reads top-to-bottom, just like synchronous code, but nothing blocks — the conveyor belt handles the waiting for you.
This second option is async/await. The await keyword is your conveyor belt — it receives a Promise<T> and delivers T to your variable. TypeScript knows exactly what T is, so you get full type safety with zero ceremony. No .then(), no callback nesting, just clean, linear code that looks synchronous but runs asynchronously.
The syntax is straightforward. You mark a function with the async keyword, and inside it, you use await before any promise. Let's see it in action:
// A function that returns a Promise
function fetchUser():
Promise<User> {
return fetch("/api/user")
.then(res => res.json());
}
// The async version — same thing, cleaner
async function fetchUserAsync():
Promise<User> {
const res = await fetch("/api/user");
const user: User = await res.json();
return user;
}
Notice a few critical things:
- The function is marked
async— this tells TypeScript the function returns aPromise. - Inside the function,
awaitis used before each promise-returning operation. await fetch()unwrapsPromise<Response>intoResponse.await res.json()unwrapsPromise<any>intoany, but we annotate it asUser.- The function returns a
Userdirectly, TypeScript automatically wraps it intoPromise<User>.
You can use await with any promise, not just fetch:
async function runSequentially() {
const timer = await wait(1000);
console.log("1 second passed");
const data =
await readFile("data.txt");
console.log(data);
const user =
await fetchUserAsync();
console.log(user.name);
}
Each await pauses the execution of the async function until the promise resolves. But — and this is crucial — the rest of your application is NOT blocked. Other code continues to run. The async function simply "yields" control and picks up where it left off when the promise resolves. This is the non-blocking nature of JavaScript combined with the readability of synchronous code.
The return type of an async function is ALWAYS Promise<T>. If you return a string, TypeScript infers Promise<string>. If you return User, it infers Promise<User>. This is automatic and you should always annotate it explicitly for clarity:
// ❌ Not annotated — inferred as Promise<string>
async function greet() {
return "Hello!";
}
// ✅ Annotated — explicit contract
async function greet(): Promise<string> {
return "Hello!";
}One of the biggest advantages of async/await over promise chains is error handling. With promises, you use .catch() which — as we learned — gives you unknown. With async/await, you can use the familiar try/catch blocks:
async function loadUser(): Promise<User | null> {
try {
const res = await fetch("/api/user");
const user: User = await res.json();
return user;
} catch (error) {
// error is unknown here
console.error("Failed to load user:", error);
return null;
}
}
The catch block catches ANY error from ANY await inside the try block. Whether the fetch itself fails, the res.json() fails, or any other promise rejects — they all end up in the same catch block. This is much cleaner than chaining .catch() after every .then().
You can also handle specific errors more granularly with multiple try/catch blocks:
async function loadDashBoard(): Promise<Dashboard> {
let user: User;
let posts: Post[];
try {
user = await fetchUser();
} catch (e) {
console.error("User fetch failed");
throw new Error("User required");
}
try {
posts = await fetchPosts(user.id);
} catch (e) {
console.error("Posts fetch failed, using empty");
posts = [];
}
return { user, posts };
}
One important nuance: the error caught in catch is typed as unknown in TypeScript (since TypeScript 4.0 with useUnknownInCatchVariables enabled through strict mode). This is intentional and forces you to narrow the error before using it:
async function safeFetch() {
try {
return await fetch("/api/data");
} catch (err) {
// err is unknown!
// WRONG: console.log(err.message);
if (err instanceof Error) {
console.log(err.message);
} else if (typeof err === "string") {
console.log(err);
} else {
console.log("Unknown error type");
}
return null;
}
}
If you need more control, you can also use .catch() on individual promises inside an async function — they work perfectly alongside try/catch:
async function example() {
const data =
await fetchData()
.catch(err => {
console.warn("Fetch failed, using default");
return DEFAULT_DATA;
});
// data is always the resolved type
// because .catch returns DEFAULT_DATA
}Async/await is clean and powerful, but it has some common pitfalls. Let's walk through the traps that trip up even experienced developers.
Trap 1: Sequential Instead of Parallel
The most common perf trap — writing await before each independent operation causes them to run one after another:
// SLOW: Sequential
const user = await fetchUser();
const posts = await fetchPosts();
// Posts waits for User!
// FAST: Parallel
const [user, posts] =
await Promise.all([
fetchUser(),
fetchPosts()
]);
Trap 2: Forgetting try/catch Entirely
An async function without try/catch will produce an unhandled promise rejection if any await inside it rejects. Always wrap your awaited code in try/catch unless you explicitly want the rejection to propagate:
// DANGEROUS: Unhandled rejection
async function load() {
const data = await riskyFetch();
// If riskyFetch rejects → BOOM!
}
// SAFE: Handle the error
async function load() {
try {
const data = await riskyFetch();
return data;
} catch (e) {
return DEFAULT_DATA;
}
}
Trap 3: Await in Loops
await inside a for or forEach loop runs sequentially, one item at a time. For independent operations, this is needlessly slow:
// SLOW: One at a time
const users = [id1, id2, id3];
for (const id of users) {
const user = await fetchUser(id);
results.push(user);
}
// FAST: All at once
const results =
await Promise.all(
users.map(id => fetchUser(id))
);
Trap 4: Top-Level Await Not Available Everywhere
You can only use await inside an async function. At the top level of a module, you need to either wrap it in an async IIFE or ensure your tsconfig uses a module system that supports top-level await (like ESNext):
// ❌ ERROR: Top-level await not allowed here
const data = await fetchData();
// ✅ FIX: Wrap in async function
async function init() {
const data = await fetchData();
return data;
}
Trap 5: async in Array Methods That Don't Work
forEach, map, filter — these do NOT work as expected with async callbacks. array.forEach(async fn) will run all callbacks concurrently without waiting, and array.filter(async fn) will filter by truthiness of promises (always truthy):
// WRONG: forEach doesn't wait
items.forEach(async item => {
await process(item);
});
// Continues before all items processed!
// RIGHT: Use for...of
for (const item of items) {
await process(item);
}Here's your complete cheatsheet for async/await in TypeScript. Pin this to your mental wall, bhai!
Basic Syntax:
async function fn(): Promise<T> {
const result: T = await somePromise;
return result; // Auto-wrapped to Promise<T>
}
Error Handling:
async function safe(): Promise<T | null> {
try {
return await riskyOp();
} catch (err: unknown) {
// err is unknown — narrow it!
return null;
}
}
Parallel Operations:
const [a, b] =
await Promise.all([
fetchA(), fetchB()
]);
Key Rules:
- Always annotate async function return types explicitly
- Use try/catch for error handling — catch error is unknown
- Use Promise.all() for independent parallel operations
- Never await inside forEach — use for...of or Promise.all with map
- Only await actual promises — avoid awaiting non-promise values
- Top-level await needs appropriate module configuration
The Golden Rule: "Async/await is the conveyor belt of your code — await unwraps the promise so you get the actual value, without blocking the kitchen. Use try/catch like a safety net underneath the belt, and Promise.all() to run multiple belts at once. Async code reads like sync, but never blocks — that's the biryani magic of TypeScript, bhai!"
Key Takeaways
- await unwraps Promise
into T — the conveyor belt that delivers the value - Async functions ALWAYS return Promise
— TypeScript auto-wraps the return value - Use try/catch for error handling in async functions — the catch parameter is unknown
- Use Promise.all() for independent parallel operations — sequential await is slower
- Avoid await in forEach — use for...of for sequential or Promise.all with map for parallel
- Annotate async function return types explicitly for clear contracts
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login