Chapter 9: Async Programming
Authored by syscook.dev
What is Async Programming in TypeScript?
Async programming in TypeScript allows you to handle asynchronous operations like API calls, file I/O, and timers without blocking the main thread. TypeScript provides excellent support for async/await, Promises, and other asynchronous patterns with strong type safety.
Key Concepts:
- Promises: Objects representing eventual completion of async operations
- async/await: Syntactic sugar for working with Promises
- Error Handling: Managing errors in async operations
- Concurrent Operations: Running multiple async operations simultaneously
- Type Safety: TypeScript's support for async types
- Async Iterators: Working with async data streams
Why Use Async Programming?
1. Non-blocking Operations
Async programming prevents blocking the main thread during long-running operations.
// Without async - blocking operation
function fetchDataSync(): string {
// Simulate blocking operation
const start = Date.now();
while (Date.now() - start < 2000) {
// Blocking for 2 seconds
}
return "Data fetched";
}
// With async - non-blocking operation
async function fetchDataAsync(): Promise<string> {
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 2000));
return "Data fetched";
}
// Usage
console.log("Starting async operation...");
fetchDataAsync().then(result => {
console.log("Result:", result);
});
console.log("This runs immediately, not blocked!");
Output:
Starting async operation...
This runs immediately, not blocked!
Result: Data fetched
2. Better Error Handling
Async programming provides structured error handling for asynchronous operations.
// Async function with error handling
async function fetchUserData(userId: number): Promise<{ id: number; name: string; email: string }> {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
if (userId <= 0) {
throw new Error("Invalid user ID");
}
return {
id: userId,
name: "John Doe",
email: "[email protected]"
};
} catch (error) {
console.error("Error fetching user data:", error);
throw error;
}
}
// Usage with error handling
async function handleUserData() {
try {
const userData = await fetchUserData(1);
console.log("User data:", userData);
} catch (error) {
console.error("Failed to fetch user data:", error.message);
}
}
handleUserData();
Output:
User data: { id: 1, name: 'John Doe', email: '[email protected]' }
How to Use Async Programming?
1. Promises
Basic Promise Usage
// Creating a Promise
function createPromise<T>(value: T, delay: number = 1000): Promise<T> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.1) { // 90% success rate
resolve(value);
} else {
reject(new Error("Random failure"));
}
}, delay);
});
}
// Using Promises
const stringPromise = createPromise("Hello World", 1000);
const numberPromise = createPromise(42, 1500);
const objectPromise = createPromise({ name: "John", age: 30 }, 2000);
// Promise handling
stringPromise
.then(result => {
console.log("String result:", result);
return numberPromise;
})
.then(result => {
console.log("Number result:", result);
return objectPromise;
})
.then(result => {
console.log("Object result:", result);
})
.catch(error => {
console.error("Promise error:", error.message);
});
console.log("Promises started...");
Output:
Promises started...
String result: Hello World
Number result: 42
Object result: { name: 'John', age: 30 }
Promise Utility Methods
// Promise.all - wait for all promises to resolve
async function fetchAllData() {
const promises = [
createPromise("Data 1", 1000),
createPromise("Data 2", 1500),
createPromise("Data 3", 2000)
];
try {
const results = await Promise.all(promises);
console.log("All data fetched:", results);
} catch (error) {
console.error("One or more promises failed:", error.message);
}
}
// Promise.allSettled - wait for all promises to settle
async function fetchAllSettled() {
const promises = [
createPromise("Success 1", 1000),
createPromise("Success 2", 1500),
createPromise("Failure", 2000) // This will fail
];
const results = await Promise.allSettled(promises);
console.log("All settled results:");
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Promise ${index + 1}:`, result.value);
} else {
console.log(`Promise ${index + 1} failed:`, result.reason.message);
}
});
}
// Promise.race - first promise to settle wins
async function fetchRace() {
const promises = [
createPromise("Fast", 500),
createPromise("Medium", 1000),
createPromise("Slow", 2000)
];
try {
const result = await Promise.race(promises);
console.log("Race winner:", result);
} catch (error) {
console.error("Race failed:", error.message);
}
}
// Usage
fetchAllData();
setTimeout(() => fetchAllSettled(), 3000);
setTimeout(() => fetchRace(), 6000);
Output:
All data fetched: ['Data 1', 'Data 2', 'Data 3']
All settled results:
Promise 1: Success 1
Promise 2: Success 2
Promise 3 failed: Random failure
Race winner: Fast
2. async/await
Basic async/await
// Async function with await
async function fetchUserProfile(userId: number): Promise<{ id: number; name: string; profile: any }> {
console.log(`Fetching user ${userId}...`);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
const user = {
id: userId,
name: `User ${userId}`,
profile: {
avatar: `avatar_${userId}.jpg`,
bio: `Bio for user ${userId}`,
joinDate: new Date().toISOString()
}
};
console.log(`User ${userId} fetched successfully`);
return user;
}
// Async function with multiple awaits
async function fetchUserWithPosts(userId: number) {
try {
// Fetch user profile
const user = await fetchUserProfile(userId);
// Fetch user posts
console.log(`Fetching posts for user ${userId}...`);
await new Promise(resolve => setTimeout(resolve, 800));
const posts = [
{ id: 1, title: "Post 1", content: "Content 1" },
{ id: 2, title: "Post 2", content: "Content 2" }
];
console.log(`Posts for user ${userId} fetched successfully`);
return {
...user,
posts
};
} catch (error) {
console.error(`Error fetching user ${userId}:`, error);
throw error;
}
}
// Usage
async function main() {
try {
const userWithPosts = await fetchUserWithPosts(1);
console.log("Complete user data:", userWithPosts);
} catch (error) {
console.error("Main error:", error.message);
}
}
main();
Output:
Fetching user 1...
User 1 fetched successfully
Fetching posts for user 1...
Posts for user 1 fetched successfully
Complete user data: {
id: 1,
name: 'User 1',
profile: {
avatar: 'avatar_1.jpg',
bio: 'Bio for user 1',
joinDate: '2024-01-15T10:30:00.000Z'
},
posts: [
{ id: 1, title: 'Post 1', content: 'Content 1' },
{ id: 2, title: 'Post 2', content: 'Content 2' }
]
}
Concurrent async/await
// Sequential execution
async function fetchSequential() {
console.log("Sequential execution:");
const start = Date.now();
const user1 = await fetchUserProfile(1);
const user2 = await fetchUserProfile(2);
const user3 = await fetchUserProfile(3);
const end = Date.now();
console.log(`Sequential took: ${end - start}ms`);
return [user1, user2, user3];
}
// Concurrent execution
async function fetchConcurrent() {
console.log("Concurrent execution:");
const start = Date.now();
const [user1, user2, user3] = await Promise.all([
fetchUserProfile(1),
fetchUserProfile(2),
fetchUserProfile(3)
]);
const end = Date.now();
console.log(`Concurrent took: ${end - start}ms`);
return [user1, user2, user3];
}
// Usage
async function compareExecution() {
await fetchSequential();
console.log("---");
await fetchConcurrent();
}
compareExecution();
Output:
Sequential execution:
Fetching user 1...
User 1 fetched successfully
Fetching user 2...
User 2 fetched successfully
Fetching user 3...
User 3 fetched successfully
Sequential took: 3000ms
---
Concurrent execution:
Fetching user 1...
Fetching user 2...
Fetching user 3...
User 1 fetched successfully
User 2 fetched successfully
User 3 fetched successfully
Concurrent took: 1000ms
3. Error Handling
Try-Catch with async/await
// Async function with comprehensive error handling
async function fetchDataWithRetry<T>(
fetchFn: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempt ${attempt}/${maxRetries}`);
const result = await fetchFn();
console.log(`Success on attempt ${attempt}`);
return result;
} catch (error) {
lastError = error as Error;
console.log(`Attempt ${attempt} failed:`, error.message);
if (attempt < maxRetries) {
console.log(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
}
}
}
throw new Error(`Failed after ${maxRetries} attempts. Last error: ${lastError.message}`);
}
// Simulate a flaky API
async function flakyApiCall(): Promise<string> {
await new Promise(resolve => setTimeout(resolve, 500));
if (Math.random() < 0.7) { // 70% failure rate
throw new Error("API temporarily unavailable");
}
return "API data fetched successfully";
}
// Usage
async function handleFlakyApi() {
try {
const result = await fetchDataWithRetry(flakyApiCall, 3, 1000);
console.log("Final result:", result);
} catch (error) {
console.error("All retries failed:", error.message);
}
}
handleFlakyApi();
Output:
Attempt 1/3
Attempt 1 failed: API temporarily unavailable
Retrying in 1000ms...
Attempt 2/3
Attempt 2 failed: API temporarily unavailable
Retrying in 2000ms...
Attempt 3/3
Success on attempt 3
Final result: API data fetched successfully
Error Types and Custom Errors
// Custom error classes
class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public endpoint: string
) {
super(message);
this.name = 'ApiError';
}
}
class ValidationError extends Error {
constructor(
message: string,
public field: string,
public value: any
) {
super(message);
this.name = 'ValidationError';
}
}
// Async function with specific error types
async function fetchUserData(userId: number): Promise<{ id: number; name: string }> {
// Simulate validation
if (typeof userId !== 'number' || userId <= 0) {
throw new ValidationError("Invalid user ID", "userId", userId);
}
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
if (userId === 404) {
throw new ApiError("User not found", 404, `/users/${userId}`);
}
if (userId === 500) {
throw new ApiError("Internal server error", 500, `/users/${userId}`);
}
return {
id: userId,
name: `User ${userId}`
};
}
// Error handling with specific error types
async function handleUserData(userId: number) {
try {
const userData = await fetchUserData(userId);
console.log("User data:", userData);
} catch (error) {
if (error instanceof ValidationError) {
console.error("Validation error:", error.message);
console.error("Field:", error.field, "Value:", error.value);
} else if (error instanceof ApiError) {
console.error("API error:", error.message);
console.error("Status:", error.statusCode, "Endpoint:", error.endpoint);
} else {
console.error("Unknown error:", error.message);
}
}
}
// Usage
handleUserData(1); // Success
handleUserData(-1); // Validation error
handleUserData(404); // API error - not found
handleUserData(500); // API error - server error
Output:
User data: { id: 1, name: 'User 1' }
Validation error: Invalid user ID
Field: userId Value: -1
API error: User not found
Status: 404 Endpoint: /users/404
API error: Internal server error
Status: 500 Endpoint: /users/500
4. Async Iterators
Basic Async Iterators
// Async generator function
async function* fetchUsersBatch(batchSize: number = 3): AsyncGenerator<{ id: number; name: string }, void, unknown> {
let currentId = 1;
while (currentId <= 10) {
console.log(`Fetching batch starting from user ${currentId}...`);
// Simulate batch API call
await new Promise(resolve => setTimeout(resolve, 1000));
const batch = [];
for (let i = 0; i < batchSize && currentId <= 10; i++) {
batch.push({
id: currentId,
name: `User ${currentId}`
});
currentId++;
}
yield* batch;
}
}
// Using async iterator
async function processUsers() {
console.log("Processing users with async iterator:");
for await (const user of fetchUsersBatch(3)) {
console.log("Processing user:", user);
// Simulate processing time
await new Promise(resolve => setTimeout(resolve, 200));
}
console.log("All users processed");
}
processUsers();
Output:
Processing users with async iterator:
Fetching batch starting from user 1...
Processing user: { id: 1, name: 'User 1' }
Processing user: { id: 2, name: 'User 2' }
Processing user: { id: 3, name: 'User 3' }
Fetching batch starting from user 4...
Processing user: { id: 4, name: 'User 4' }
Processing user: { id: 5, name: 'User 5' }
Processing user: { id: 6, name: 'User 6' }
Fetching batch starting from user 7...
Processing user: { id: 7, name: 'User 7' }
Processing user: { id: 8, name: 'User 8' }
Processing user: { id: 9, name: 'User 9' }
Fetching batch starting from user 10...
Processing user: { id: 10, name: 'User 10' }
All users processed
Async Iterator with Error Handling
// Async generator with error handling
async function* fetchDataWithErrors(): AsyncGenerator<string, void, unknown> {
const data = ["data1", "data2", "error", "data4", "data5"];
for (const item of data) {
await new Promise(resolve => setTimeout(resolve, 500));
if (item === "error") {
throw new Error("Simulated error in data stream");
}
yield item;
}
}
// Processing async iterator with error handling
async function processDataStream() {
try {
for await (const data of fetchDataWithErrors()) {
console.log("Processing:", data);
}
} catch (error) {
console.error("Error in data stream:", error.message);
}
}
processDataStream();
Output:
Processing: data1
Processing: data2
Error in data stream: Simulated error in data stream
Practical Examples
1. API Client with TypeScript
// API client with async/await and proper typing
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
userId: number;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`API request failed for ${endpoint}:`, error);
throw error;
}
}
async getUsers(): Promise<ApiResponse<User[]>> {
return this.request<User[]>('/users');
}
async getUser(id: number): Promise<ApiResponse<User>> {
return this.request<User>(`/users/${id}`);
}
async getPostsByUser(userId: number): Promise<ApiResponse<Post[]>> {
return this.request<Post[]>(`/users/${userId}/posts`);
}
async createUser(userData: Omit<User, 'id'>): Promise<ApiResponse<User>> {
return this.request<User>('/users', {
method: 'POST',
body: JSON.stringify(userData)
});
}
}
// Usage
async function demonstrateApiClient() {
const apiClient = new ApiClient('https://jsonplaceholder.typicode.com');
try {
// Fetch users
const usersResponse = await apiClient.getUsers();
console.log("Users:", usersResponse.data.slice(0, 3));
// Fetch specific user
const userResponse = await apiClient.getUser(1);
console.log("User 1:", userResponse.data);
// Fetch user's posts
const postsResponse = await apiClient.getPostsByUser(1);
console.log("User 1 posts:", postsResponse.data.slice(0, 2));
} catch (error) {
console.error("API client error:", error.message);
}
}
// Note: This would work with a real API
// demonstrateApiClient();
2. Concurrent Data Processing
// Concurrent data processing with async/await
interface DataSource {
id: string;
fetchData(): Promise<any>;
}
class DatabaseSource implements DataSource {
constructor(public id: string) {}
async fetchData(): Promise<any> {
console.log(`Fetching from database ${this.id}...`);
await new Promise(resolve => setTimeout(resolve, 1000));
return { source: 'database', id: this.id, data: `Data from DB ${this.id}` };
}
}
class ApiSource implements DataSource {
constructor(public id: string) {}
async fetchData(): Promise<any> {
console.log(`Fetching from API ${this.id}...`);
await new Promise(resolve => setTimeout(resolve, 1500));
return { source: 'api', id: this.id, data: `Data from API ${this.id}` };
}
}
class FileSource implements DataSource {
constructor(public id: string) {}
async fetchData(): Promise<any> {
console.log(`Fetching from file ${this.id}...`);
await new Promise(resolve => setTimeout(resolve, 800));
return { source: 'file', id: this.id, data: `Data from file ${this.id}` };
}
}
// Data aggregator
class DataAggregator {
private sources: DataSource[] = [];
addSource(source: DataSource): void {
this.sources.push(source);
}
async fetchAllData(): Promise<any[]> {
console.log("Fetching data from all sources concurrently...");
const start = Date.now();
const promises = this.sources.map(source => source.fetchData());
const results = await Promise.all(promises);
const end = Date.now();
console.log(`All data fetched in ${end - start}ms`);
return results;
}
async fetchDataWithTimeout(timeout: number = 2000): Promise<any[]> {
console.log(`Fetching data with ${timeout}ms timeout...`);
const promises = this.sources.map(async (source) => {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Timeout for source ${source.id}`)), timeout);
});
return Promise.race([source.fetchData(), timeoutPromise]);
});
try {
const results = await Promise.all(promises);
return results;
} catch (error) {
console.error("Some sources timed out:", error.message);
throw error;
}
}
}
// Usage
async function demonstrateDataAggregation() {
const aggregator = new DataAggregator();
// Add data sources
aggregator.addSource(new DatabaseSource('db1'));
aggregator.addSource(new ApiSource('api1'));
aggregator.addSource(new FileSource('file1'));
aggregator.addSource(new DatabaseSource('db2'));
try {
// Fetch all data concurrently
const allData = await aggregator.fetchAllData();
console.log("All data:", allData);
console.log("---");
// Fetch with timeout
const timeoutData = await aggregator.fetchDataWithTimeout(1200);
console.log("Timeout data:", timeoutData);
} catch (error) {
console.error("Data aggregation error:", error.message);
}
}
demonstrateDataAggregation();
Output:
Fetching data from all sources concurrently...
Fetching from database db1...
Fetching from API api1...
Fetching from file file1...
Fetching from database db2...
All data fetched in 1500ms
All data: [
{ source: 'database', id: 'db1', data: 'Data from DB db1' },
{ source: 'api', id: 'api1', data: 'Data from API api1' },
{ source: 'file', id: 'file1', data: 'Data from file file1' },
{ source: 'database', id: 'db2', data: 'Data from DB db2' }
]
---
Fetching data with 1200ms timeout...
Fetching from database db1...
Fetching from API api1...
Fetching from file file1...
Fetching from database db2...
Some sources timed out: Timeout for source api1
Best Practices
1. Always Handle Errors
// Good: Proper error handling
async function fetchData() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch data:', error);
throw error;
}
}
2. Use Promise.all for Concurrent Operations
// Good: Concurrent execution
async function fetchMultipleData() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
return { users, posts, comments };
}
3. Use Promise.allSettled for Independent Operations
// Good: Independent operations
async function fetchIndependentData() {
const results = await Promise.allSettled([
fetchUserData(),
fetchProductData(),
fetchOrderData()
]);
return results.map(result =>
result.status === 'fulfilled' ? result.value : null
);
}
4. Implement Proper Timeouts
// Good: Timeout implementation
async function fetchWithTimeout<T>(
promise: Promise<T>,
timeout: number
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Operation timed out')), timeout);
});
return Promise.race([promise, timeoutPromise]);
}
Common Pitfalls and Solutions
1. Forgetting await in async functions
// ❌ Problem: Missing await
async function fetchData() {
const response = fetch('/api/data'); // Missing await
return response.json(); // This will fail
}
// ✅ Solution: Use await
async function fetchData() {
const response = await fetch('/api/data');
return await response.json();
}
2. Not handling Promise rejections
// ❌ Problem: Unhandled promise rejection
async function riskyOperation() {
const data = await fetchData(); // Might throw
return processData(data);
}
// ✅ Solution: Handle errors
async function riskyOperation() {
try {
const data = await fetchData();
return processData(data);
} catch (error) {
console.error('Operation failed:', error);
throw error;
}
}
3. Blocking operations in async functions
// ❌ Problem: Blocking operation
async function badAsyncFunction() {
const data = await fetchData();
// This blocks the event loop
for (let i = 0; i < 1000000; i++) {
// Heavy computation
}
return data;
}
// ✅ Solution: Use setImmediate or worker threads
async function goodAsyncFunction() {
const data = await fetchData();
// Yield control back to event loop
await new Promise(resolve => setImmediate(resolve));
return data;
}
Conclusion
Async programming in TypeScript provides powerful tools for handling asynchronous operations with type safety. By understanding:
- What async programming is and its key concepts
- Why it's essential for non-blocking operations and better error handling
- How to use Promises, async/await, and async iterators
You can create responsive applications that handle complex asynchronous workflows efficiently. TypeScript's type system ensures that async operations are type-safe, making it easier to catch errors at compile time and maintain robust applications.
Next Steps
- Practice creating async functions and handling errors
- Explore advanced async patterns like async iterators
- Learn about async testing strategies
- Move on to Chapter 10: Error Handling
This tutorial is part of the TypeScript Mastery series by syscook.dev