Promises & Async/Await
Introduction
Callbacks work, but they can quickly become messy. When you need to run multiple async tasks in sequence, you often end up with nested callbacks inside callbacks. This is called callback hell .
Promises were introduced to solve this by providing a cleaner way to handle asynchronous operations. And async/await makes promises even easier to work with by allowing asynchronous code to look and feel like synchronous code.
TypeScript provides excellent type safety for promises, allowing you to specify what type of value a promise will resolve to, preventing many runtime errors.
The Problem: Callback Hell
Callback Hell (nested callbacks):
getUser(function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0], function(details) {
getShippingInfo(details.id, function(shipping) {
console.log("Shipping:", shipping);
// This is hard to read and maintain!
});
});
});
});
Solution with Promises:
getUser()
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0]))
.then(details => getShippingInfo(details.id))
.then(shipping => console.log("Shipping:", shipping))
.catch(error => console.error(error));
Even Better with Async/Await:
async function getShippingData() {
try {
const user = await getUser();
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0]);
const shipping = await getShippingInfo(details.id);
console.log("Shipping:", shipping);
} catch (error) {
console.error(error);
}
}
Concepts
Creating Promises
A Promise represents a value that may be available now, later, or never:
function fetchUser(id: number): Promise<{ name: string; age: number }> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ name: "Alice", age: 30 });
} else {
reject(new Error("Invalid ID"));
}
}, 1000);
});
}
Promise States
A promise can be in one of three states:
- Pending - Initial state, neither fulfilled nor rejected
- Fulfilled - Operation completed successfully
- Rejected - Operation failed
const promise = new Promise<string>((resolve, reject) => {
// Promise is PENDING here
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("Success!"); // Promise becomes FULFILLED
} else {
reject(new Error("Failed!")); // Promise becomes REJECTED
}
}, 1000);
});
Using Promises with .then()
fetchUser(1)
.then((user) => {
console.log(user.name); // TypeScript knows the structure
return user.age;
})
.then((age) => {
console.log(`Age: ${age}`);
})
.catch((error) => {
console.error("Error:", error.message);
})
.finally(() => {
console.log("Done"); // Always executes
});
Async/Await
The async keyword marks a function as asynchronous and makes it return a Promise. The await keyword pauses execution until the promise resolves:
async function getUserData(id: number): Promise<void> {
try {
const user = await fetchUser(id);
console.log(user.name);
console.log(user.age);
} catch (error) {
console.error("Error:", error);
}
}
Promise.all() - Parallel Execution
Run multiple promises in parallel and wait for all to complete:
async function fetchMultipleUsers(): Promise<void> {
const [user1, user2, user3] = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3)
]);
console.log(user1, user2, user3);
}
// All three requests happen simultaneously!
Promise.race()
Returns the first promise that settles (fulfills or rejects):
async function fetchWithTimeout(): Promise<string> {
const timeout = new Promise<string>((_, reject) => {
setTimeout(() => reject(new Error("Timeout")), 5000);
});
const dataFetch = fetchData();
return Promise.race([dataFetch, timeout]);
}
Promise.allSettled()
Waits for all promises to settle, regardless of success or failure:
async function fetchAllUsers(): Promise<void> {
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(-1), // This will reject
fetchUser(3)
]);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`User ${index}:`, result.value);
} else {
console.log(`User ${index} failed:`, result.reason);
}
});
}
Promise.any()
Returns the first promise that fulfills (ignores rejections):
async function fetchFromFastestServer(): Promise<string> {
const result = await Promise.any([
fetch("https://server1.com/data"),
fetch("https://server2.com/data"),
fetch("https://server3.com/data")
]);
return result;
}
Examples
API Service with Promises
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
class ApiService {
private baseUrl = "https://api.example.com";
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.2;
if (success) {
resolve({
data: {} as T,
status: 200,
message: "Success"
});
} else {
reject(new Error("Network error"));
}
}, 1000);
});
}
async post<T, U>(endpoint: string, body: U): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
data: {} as T,
status: 201,
message: "Created"
});
}, 1000);
});
}
async delete(endpoint: string): Promise<ApiResponse<null>> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
data: null,
status: 204,
message: "Deleted"
});
}, 500);
});
}
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
const api = new ApiService();
async function fetchUserData(): Promise<void> {
try {
console.log("Fetching user...");
const response = await api.get<User>("/users/1");
console.log("User fetched:", response);
} catch (error) {
console.error("Failed to fetch user:", error);
}
}
fetchUserData();
Sequential vs Parallel Execution
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function fetchData(id: number): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`Data ${id}`);
}, 1000);
});
}
// Sequential - waits for each operation
async function sequential(): Promise<void> {
console.log("Starting sequential...");
const start = Date.now();
const result1 = await fetchData(1); // Wait 1 second
const result2 = await fetchData(2); // Wait 1 second
const result3 = await fetchData(3); // Wait 1 second
const elapsed = Date.now() - start;
console.log(`Sequential completed in ${elapsed}ms`);
console.log(result1, result2, result3);
// Total time: ~3 seconds
}
// Parallel - all operations at once
async function parallel(): Promise<void> {
console.log("Starting parallel...");
const start = Date.now();
const [result1, result2, result3] = await Promise.all([
fetchData(1),
fetchData(2),
fetchData(3)
]);
const elapsed = Date.now() - start;
console.log(`Parallel completed in ${elapsed}ms`);
console.log(result1, result2, result3);
// Total time: ~1 second
}
sequential(); // ~3 seconds
parallel(); // ~1 second
Error Handling Patterns
class DataService {
async fetchWithRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} attempts: ${error}`);
}
console.log(`Attempt ${attempt} failed, retrying...`);
await delay(1000 * attempt); // Exponential backoff
}
}
throw new Error("Unexpected error");
}
async fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number
): Promise<T> {
const timeout = new Promise<T>((_, reject) => {
setTimeout(() => {
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]);
}
async fetchWithFallback<T>(
primary: () => Promise<T>,
fallback: () => Promise<T>
): Promise<T> {
try {
return await primary();
} catch (error) {
console.log("Primary failed, using fallback");
return await fallback();
}
}
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
const service = new DataService();
async function example(): Promise<void> {
// Retry example
try {
const result = await service.fetchWithRetry(async () => {
if (Math.random() > 0.7) {
return "Success!";
}
throw new Error("Random failure");
});
console.log("Result:", result);
} catch (error) {
console.error("All retries failed:", error);
}
}
Promise Chaining
interface User {
id: number;
name: string;
}
interface Order {
id: number;
userId: number;
total: number;
}
interface OrderDetail {
orderId: number;
items: string[];
shipping: string;
}
class OrderService {
getUser(id: number): Promise<User> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id, name: "Alice" });
}, 500);
});
}
getOrders(userId: number): Promise<Order[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, userId, total: 100 },
{ id: 2, userId, total: 200 }
]);
}, 500);
});
}
getOrderDetails(orderId: number): Promise<OrderDetail> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
orderId,
items: ["Item 1", "Item 2"],
shipping: "Express"
});
}, 500);
});
}
// Using promise chaining
getUserOrdersChained(userId: number): Promise<OrderDetail> {
return this.getUser(userId)
.then(user => {
console.log("Got user:", user.name);
return this.getOrders(user.id);
})
.then(orders => {
console.log("Got orders:", orders.length);
return this.getOrderDetails(orders[0].id);
})
.then(details => {
console.log("Got details:", details);
return details;
});
}
// Using async/await (cleaner!)
async getUserOrdersAsync(userId: number): Promise<OrderDetail> {
const user = await this.getUser(userId);
console.log("Got user:", user.name);
const orders = await this.getOrders(user.id);
console.log("Got orders:", orders.length);
const details = await this.getOrderDetails(orders[0].id);
console.log("Got details:", details);
return details;
}
}
// Usage
const orderService = new OrderService();
orderService.getUserOrdersChained(1)
.then(details => console.log("Final result:", details))
.catch(error => console.error("Error:", error));
// Or with async/await
(async () => {
try {
const details = await orderService.getUserOrdersAsync(1);
console.log("Final result:", details);
} catch (error) {
console.error("Error:", error);
}
})();
Challenges
1. Create Delay Function
- Create a
delayfunction that returns a promise - Resolves after specified milliseconds
💡 Show Answer
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function example(): Promise `<void>` {
console.log("Start");
await delay(2000);
console.log("After 2 seconds");
await delay(1000);
console.log("After 3 seconds total");
}
example();
2. Fetch with Timeout
- Add timeout to a promise
- Reject if takes too long
- Use Promise.race()
💡 Show Answer
function fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number
): Promise<T> {
const timeout = new Promise<T>((_, reject) => {
setTimeout(() => {
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]);
}
// Simulate slow fetch
function slowFetch(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve("Data"), 3000);
});
}
async function example(): Promise<void> {
try {
const result = await fetchWithTimeout(slowFetch(), 1000);
console.log(result);
} catch (error) {
console.error(error); // "Operation timed out after 1000ms"
}
}
example();
3. Retry Logic
- Retry a promise if it fails
- Maximum number of attempts
- Exponential backoff
💡 Show Answer
async function retry<T>(
fn: () => Promise<T>,
maxAttempts: number,
delayMs: number = 1000
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) {
throw new Error(`Failed after ${maxAttempts} attempts: ${error}`);
}
console.log(`Attempt ${attempt} failed, retrying in ${delayMs * attempt}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs * attempt));
}
}
throw new Error("Unexpected error");
}
// Simulate unreliable operation
let attemptCount = 0;
function unreliableOperation(): Promise `<string>` {
return new Promise((resolve, reject) => {
attemptCount++;
if (attemptCount < 3) {
reject(new Error("Failed"));
} else {
resolve("Success!");
}
});
}
async function example(): Promise `<void>` {
try {
const result = await retry(unreliableOperation, 5, 500);
console.log("Result:", result); // "Success!" on 3rd attempt
} catch (error) {
console.error("All retries failed:", error);
}
}
example();
4. Promise.all with Mixed Results
- Fetch multiple resources
- Handle partial failures
- Use Promise.allSettled
💡 Show Answer
async function fetchResource(id: number): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 2) {
reject(new Error(`Resource ${id} not found`));
} else {
resolve(`Resource ${id} data`);
}
}, 1000);
});
}
async function fetchMultipleResources(): Promise<void> {
const ids = [1, 2, 3, 4, 5];
const results = await Promise.allSettled(
ids.map(id => fetchResource(id))
);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`✓ Resource ${index + 1}:`, result.value);
} else {
console.log(`✗ Resource ${index + 1}:`, result.reason.message);
}
});
const successful = results.filter(r => r.status === "fulfilled").length;
console.log(`\n${successful}/${ids.length} resources fetched successfully`);
}
fetchMultipleResources();
5. Sequential Processing
- Process items one at a time
- Wait for each to complete
- Return all results
💡 Show Answer
async function processItem(item: number): Promise<number> {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Processing item ${item}`);
resolve(item * 2);
}, 500);
});
}
async function processSequentially(items: number[]): Promise<number[]> {
const results: number[] = [];
for (const item of items) {
const result = await processItem(item);
results.push(result);
}
return results;
}
async function example(): Promise `<void>` {
const items = [1, 2, 3, 4, 5];
console.log("Starting sequential processing...");
const start = Date.now();
const results = await processSequentially(items);
const elapsed = Date.now() - start;
console.log("Results:", results);
console.log(`Completed in ${elapsed}ms`); // ~2500ms (5 items × 500ms)
}
example();
6. Parallel Processing with Limit
- Process items in parallel
- But limit concurrent operations
- Use Promise.all in batches
💡 Show Answer
async function processItem(item: number): Promise<number> {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Processed item ${item}`);
resolve(item * 2);
}, 1000);
});
}
async function processWithLimit<T, U>(
items: T[],
processFn: (item: T) => Promise<U>,
limit: number
): Promise<U[]> {
const results: U[] = [];
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit);
const batchResults = await Promise.all(batch.map(processFn));
results.push(...batchResults);
}
return results;
}
async function example(): Promise<void> {
const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log("Processing with limit of 3...");
const start = Date.now();
const results = await processWithLimit(items, processItem, 3);
const elapsed = Date.now() - start;
console.log("Results:", results);
console.log(`Completed in ${elapsed}ms`); // ~4000ms (4 batches × 1000ms)
}
example();
7. Cache with Promises
- Implement simple cache
- Store promise results
- Return cached value if available
💡 Show Answer
class PromiseCache<T> {
private cache: Map<string, T> = new Map();
private pending: Map<string, Promise<T>> = new Map();
async get(
key: string,
fetcher: () => Promise `<T>`
): Promise `<T>` {
// Return cached value if available
if (this.cache.has(key)) {
console.log(`Cache hit: ${key}`);
return this.cache.get(key)!;
}
// Return pending promise if already fetching
if (this.pending.has(key)) {
console.log(`Using pending promise: ${key}`);
return this.pending.get(key)!;
}
// Fetch new data
console.log(`Cache miss: ${key}`);
const promise = fetcher();
this.pending.set(key, promise);
try {
const result = await promise;
this.cache.set(key, result);
this.pending.delete(key);
return result;
} catch (error) {
this.pending.delete(key);
throw error;
}
}
clear(): void {
this.cache.clear();
this.pending.clear();
}
delete(key: string): void {
this.cache.delete(key);
this.pending.delete(key);
}
}
// Usage
const cache = new PromiseCache `<string>`();
async function fetchUser(id: number): Promise `<string>` {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`User ${id} data`);
}, 2000);
});
}
async function example(): Promise `<void>` {
console.log("First call:");
await cache.get("user1", () => fetchUser(1));
console.log("\nSecond call (cached):");
await cache.get("user1", () => fetchUser(1));
console.log("\nThird call (different key):");
await cache.get("user2", () => fetchUser(2));
}
example();
8. Debounced Promise
- Debounce promise creation
- Cancel previous if new one arrives
- Return only latest result
💡 Show Answer
class DebouncedPromise<T> {
private timeoutId: NodeJS.Timeout | null = null;
private pendingResolve: ((value: T) => void) | null = null;
execute(
fn: () => Promise<T>,
delayMs: number
): Promise<T> {
return new Promise((resolve, reject) => {
// Cancel previous timeout
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
// Reject previous promise if exists
if (this.pendingResolve) {
this.pendingResolve = null;
}
this.pendingResolve = resolve;
this.timeoutId = setTimeout(async () => {
try {
const result = await fn();
if (this.pendingResolve === resolve) {
resolve(result);
}
} catch (error) {
reject(error);
} finally {
this.timeoutId = null;
this.pendingResolve = null;
}
}, delayMs);
});
}
}
// Usage - simulate search input
const debouncedSearch = new DebouncedPromise<string[]>();
async function search(query: string): Promise<string[]> {
console.log(`Searching for: ${query}`);
return new Promise((resolve) => {
setTimeout(() => {
resolve([`${query}1`, `${query}2`, `${query}3`]);
}, 500);
});
}
async function example(): Promise<void> {
// Simulate rapid typing
debouncedSearch.execute(() => search("a"), 300);
await new Promise(r => setTimeout(r, 100));
debouncedSearch.execute(() => search("ab"), 300);
await new Promise(r => setTimeout(r, 100));
const results = await debouncedSearch.execute(() => search("abc"), 300);
console.log("Final results:", results);
// Only "Searching for: abc" will execute
}
example();
9. Promise Pool
- Execute promises with concurrency limit
- Process as slots become available
- Track progress
💡 Show Answer
class PromisePool<T> {
private queue: Array<() => Promise<T>> = [];
private active = 0;
private results: T[] = [];
constructor(private concurrency: number) {}
async add(promiseFn: () => Promise `<T>`): Promise `<void>` {
this.queue.push(promiseFn);
}
async run(): Promise<T[]> {
const workers: Promise `<void>`[] = [];
for (let i = 0; i < this.concurrency; i++) {
workers.push(this.worker());
}
await Promise.all(workers);
return this.results;
}
private async worker(): Promise `<void>` {
while (this.queue.length > 0) {
const promiseFn = this.queue.shift();
if (!promiseFn) break;
this.active++;
console.log(`Active: ${this.active}, Queue: ${this.queue.length}`);
try {
const result = await promiseFn();
this.results.push(result);
} catch (error) {
console.error("Task failed:", error);
}
this.active--;
}
}
}
// Usage
async function fetchData(id: number): Promise `<string>` {
return new Promise((resolve) => {
const delay = Math.random() * 2000 + 500;
setTimeout(() => {
console.log(`Fetched data ${id}`);
resolve(`Data ${id}`);
}, delay);
});
}
async function example(): Promise `<void>` {
const pool = new PromisePool `<string>`(3); // Max 3 concurrent
// Add 10 tasks
for (let i = 1; i <= 10; i++) {
await pool.add(() => fetchData(i));
}
console.log("Starting pool...");
const results = await pool.run();
console.log("All done:", results);
}
example();
10. async/await Error Patterns
- Demonstrate various error handling patterns
- Try/catch, .catch(), Promise.allSettled
- Graceful degradation
💡 Show Answer
// Pattern 1: Try/Catch
async function pattern1(): Promise<void> {
try {
const result = await riskyOperation();
console.log("Success:", result);
} catch (error) {
console.error("Error:", error);
}
}
// Pattern 2: Try/Catch with Finally
async function pattern2(): Promise<void> {
try {
const result = await riskyOperation();
console.log("Success:", result);
} catch (error) {
console.error("Error:", error);
} finally {
console.log("Cleanup");
}
}
// Pattern 3: Multiple Try/Catch Blocks
async function pattern3(): Promise<void> {
let userData;
let orderData;
try {
userData = await fetchUser();
} catch (error) {
console.error("User fetch failed:", error);
userData = null;
}
try {
orderData = await fetchOrders();
} catch (error) {
console.error("Orders fetch failed:", error);
orderData = [];
}
console.log("Data:", userData, orderData);
}
// Pattern 4: Promise.allSettled for Partial Success
async function pattern4(): Promise<void> {
const results = await Promise.allSettled([
fetchUser(),
fetchOrders(),
fetchSettings()
]);
const [userResult, ordersResult, settingsResult] = results;
const user = userResult.status === "fulfilled" ? userResult.value : null;
const orders = ordersResult.status === "fulfilled" ? ordersResult.value : [];
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : {};
console.log("Partial data:", { user, orders, settings });
}
// Pattern 5: Wrapper Function for Consistent Error Handling
async function safeAsync<T>(
promise: Promise<T>
): Promise<[T | null, Error | null]> {
try {
const data = await promise;
return [data, null];
} catch (error) {
return [null, error as Error];
}
}
async function pattern5(): Promise<void> {
const [user, userError] = await safeAsync(fetchUser());
if (userError) {
console.error("User error:", userError);
return;
}
const [orders, ordersError] = await safeAsync(fetchOrders());
if (ordersError) {
console.error("Orders error:", ordersError);
// Continue anyway
}
console.log("Data:", user, orders);
}
// Helper functions
function riskyOperation(): Promise<string> {
return new Promise((resolve, reject) => {
Math.random() > 0.5 ? resolve("OK") : reject(new Error("Failed"));
});
}
function fetchUser(): Promise<{ name: string }> {
return Promise.resolve({ name: "Alice" });
}
function fetchOrders(): Promise<string[]> {
return Promise.resolve(["Order1", "Order2"]);
}
function fetchSettings(): Promise<Record<string, any>> {
return Promise.resolve({ theme: "dark" });
}
// Run examples
pattern1();
pattern2();
pattern3();
pattern4();
pattern5();
Summary
In this chapter, you learned:
✅ How promises work and their three states
✅ Creating and consuming promises
✅ async/await syntax for cleaner async code
✅ Promise.all(), Promise.race(), Promise.allSettled(), Promise.any()
✅ Error handling with try/catch
✅ Sequential vs parallel execution
✅ Common patterns: retry, timeout, caching
✅ Promise chaining vs async/await
Key Takeaways:
- Promises represent future values
- async functions always return promises
- await pauses execution until promise resolves
- Use Promise.all() for parallel operations
- Try/catch handles async errors cleanly
- Async/await is syntactic sugar over promises
- Choose sequential or parallel based on dependencies
Best Practices:
- Prefer async/await over .then() chains
- Always handle errors with try/catch
- Use Promise.all() when operations are independent
- Add timeouts for network operations
- Implement retry logic for unreliable operations
- Cache results when appropriate
- Use Promise.allSettled() for partial success scenarios
Common Patterns:
- Retry with exponential backoff
- Timeout with Promise.race()
- Parallel processing with limits
- Caching promises
- Debouncing async operations
In the next chapter, we'll explore Generics and learn how to write type-safe, reusable code!
Continue to Chapter 13: Generics →