Lesson 2.5: Practice - Sequential Requests
Duration: 60 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Build a complete data loader using Promises
- Chain dependent API calls correctly
- Handle errors at each stage of the chain
- Combine sequential and parallel operations
- Apply all Promise concepts in a real-world scenario
Project Overview
We will build a User Data Loader that:
- Fetches a user by ID
- Gets all posts for that user
- Retrieves comments for each post
- Compiles everything into a comprehensive user profile
This simulates a common real-world pattern: loading related data from an API.
Setting Up the Data Types
First, let us define our TypeScript interfaces:
// Types for our data model
interface User {
id: number;
name: string;
email: string;
username: string;
}
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
interface Comment {
id: number;
postId: number;
name: string;
email: string;
body: string;
}
// The final compiled result
interface UserProfile {
user: User;
posts: PostWithComments[];
stats: {
totalPosts: number;
totalComments: number;
averageCommentsPerPost: number;
};
}
interface PostWithComments {
post: Post;
comments: Comment[];
}
Creating the API Functions
We will simulate API calls with delays. In a real application, you would use fetch():
// Simulated database
const usersDb: User[] = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', username: 'alice' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', username: 'bob' },
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com', username: 'charlie' },
];
const postsDb: Post[] = [
{ id: 1, userId: 1, title: 'Getting Started with TypeScript', body: 'TypeScript is amazing...' },
{ id: 2, userId: 1, title: 'Understanding Promises', body: 'Promises simplify async...' },
{ id: 3, userId: 2, title: 'CSS Tips', body: 'Here are some CSS tips...' },
{ id: 4, userId: 1, title: 'Node.js Best Practices', body: 'When working with Node...' },
];
const commentsDb: Comment[] = [
{
id: 1,
postId: 1,
name: 'Great post!',
email: 'reader@example.com',
body: 'Very helpful, thanks!',
},
{ id: 2, postId: 1, name: 'Question', email: 'newbie@example.com', body: 'How do I install TS?' },
{
id: 3,
postId: 2,
name: 'Awesome',
email: 'fan@example.com',
body: 'Finally I understand promises!',
},
{ id: 4, postId: 2, name: 'Thanks', email: 'dev@example.com', body: 'Clear explanation' },
{ id: 5, postId: 4, name: 'Helpful', email: 'student@example.com', body: 'Good practices' },
];
Now let us create the API functions:
function fetchUser(userId: number): Promise<User> {
return new Promise((resolve, reject) => {
console.log(`Fetching user ${userId}...`);
setTimeout(() => {
const user = usersDb.find((u) => u.id === userId);
if (user) {
console.log(`User ${userId} found: ${user.name}`);
resolve(user);
} else {
reject(new Error(`User ${userId} not found`));
}
}, 300);
});
}
function fetchPostsByUser(userId: number): Promise<Post[]> {
return new Promise((resolve, reject) => {
console.log(`Fetching posts for user ${userId}...`);
setTimeout(() => {
const posts = postsDb.filter((p) => p.userId === userId);
console.log(`Found ${posts.length} posts for user ${userId}`);
resolve(posts);
}, 400);
});
}
function fetchCommentsByPost(postId: number): Promise<Comment[]> {
return new Promise((resolve) => {
console.log(`Fetching comments for post ${postId}...`);
setTimeout(() => {
const comments = commentsDb.filter((c) => c.postId === postId);
console.log(`Found ${comments.length} comments for post ${postId}`);
resolve(comments);
}, 200);
});
}
Step 1: Basic Sequential Loading
Let us start with a simple chain that loads a user and their posts:
function loadUserWithPosts(userId: number): Promise<{ user: User; posts: Post[] }> {
let loadedUser: User;
return fetchUser(userId)
.then((user) => {
loadedUser = user;
return fetchPostsByUser(user.id);
})
.then((posts) => {
return {
user: loadedUser,
posts: posts,
};
});
}
// Usage
loadUserWithPosts(1)
.then((result) => {
console.log('\n=== Result ===');
console.log(`User: ${result.user.name}`);
console.log(`Posts: ${result.posts.length}`);
result.posts.forEach((post) => {
console.log(` - ${post.title}`);
});
})
.catch((error) => {
console.error('Error:', error.message);
});
Output:
Fetching user 1...
User 1 found: Alice Johnson
Fetching posts for user 1...
Found 3 posts for user 1
=== Result ===
User: Alice Johnson
Posts: 3
- Getting Started with TypeScript
- Understanding Promises
- Node.js Best Practices
Step 2: Adding Comments (Sequential)
Now let us add comments for each post. First, the sequential (slow) approach:
function loadUserProfileSequential(userId: number): Promise<UserProfile> {
let loadedUser: User;
let loadedPosts: Post[];
const postsWithComments: PostWithComments[] = [];
return fetchUser(userId)
.then((user) => {
loadedUser = user;
return fetchPostsByUser(user.id);
})
.then((posts) => {
loadedPosts = posts;
// Sequential: load comments one post at a time
let chain = Promise.resolve<Comment[]>([]);
posts.forEach((post) => {
chain = chain
.then(() => fetchCommentsByPost(post.id))
.then((comments) => {
postsWithComments.push({ post, comments });
return comments;
});
});
return chain;
})
.then(() => {
const totalComments = postsWithComments.reduce((sum, p) => sum + p.comments.length, 0);
return {
user: loadedUser,
posts: postsWithComments,
stats: {
totalPosts: postsWithComments.length,
totalComments,
averageCommentsPerPost:
postsWithComments.length > 0 ? totalComments / postsWithComments.length : 0,
},
};
});
}
This works, but it is slow because we load comments one by one.
Step 3: Adding Comments (Parallel)
Let us optimize by loading comments in parallel:
function loadUserProfileParallel(userId: number): Promise<UserProfile> {
let loadedUser: User;
return fetchUser(userId)
.then((user) => {
loadedUser = user;
return fetchPostsByUser(user.id);
})
.then((posts) => {
// Parallel: load all comments at once
const commentPromises = posts.map((post) =>
fetchCommentsByPost(post.id).then((comments) => ({
post,
comments,
}))
);
return Promise.all(commentPromises);
})
.then((postsWithComments) => {
const totalComments = postsWithComments.reduce((sum, p) => sum + p.comments.length, 0);
return {
user: loadedUser,
posts: postsWithComments,
stats: {
totalPosts: postsWithComments.length,
totalComments,
averageCommentsPerPost:
postsWithComments.length > 0 ? totalComments / postsWithComments.length : 0,
},
};
});
}
// Usage with timing
console.log('Starting parallel load...');
const startTime = Date.now();
loadUserProfileParallel(1)
.then((profile) => {
const elapsed = Date.now() - startTime;
console.log('\n=== User Profile ===');
console.log(`Name: ${profile.user.name}`);
console.log(`Email: ${profile.user.email}`);
console.log(`\nPosts (${profile.stats.totalPosts}):`);
profile.posts.forEach(({ post, comments }) => {
console.log(`\n "${post.title}"`);
console.log(` Comments (${comments.length}):`);
comments.forEach((c) => {
console.log(` - ${c.name}: "${c.body.substring(0, 30)}..."`);
});
});
console.log(`\nStats:`);
console.log(` Total posts: ${profile.stats.totalPosts}`);
console.log(` Total comments: ${profile.stats.totalComments}`);
console.log(` Avg comments/post: ${profile.stats.averageCommentsPerPost.toFixed(1)}`);
console.log(`\nLoaded in ${elapsed}ms`);
})
.catch((error) => {
console.error('Failed to load profile:', error.message);
});
Step 4: Adding Error Handling
Let us add robust error handling at each stage:
class DataLoadError extends Error {
constructor(
message: string,
public stage: 'user' | 'posts' | 'comments',
public originalError?: Error
) {
super(message);
this.name = 'DataLoadError';
}
}
function loadUserProfileWithErrorHandling(userId: number): Promise<UserProfile> {
let loadedUser: User;
return fetchUser(userId)
.catch((error) => {
throw new DataLoadError(`Failed to load user ${userId}`, 'user', error);
})
.then((user) => {
loadedUser = user;
return fetchPostsByUser(user.id).catch((error) => {
throw new DataLoadError(`Failed to load posts for user ${userId}`, 'posts', error);
});
})
.then((posts) => {
const commentPromises = posts.map((post) =>
fetchCommentsByPost(post.id)
.then((comments) => ({ post, comments }))
.catch((error) => {
// For comments, we might want to continue with empty array
console.warn(`Failed to load comments for post ${post.id}`);
return { post, comments: [] as Comment[] };
})
);
return Promise.all(commentPromises);
})
.then((postsWithComments) => {
const totalComments = postsWithComments.reduce((sum, p) => sum + p.comments.length, 0);
return {
user: loadedUser,
posts: postsWithComments,
stats: {
totalPosts: postsWithComments.length,
totalComments,
averageCommentsPerPost:
postsWithComments.length > 0 ? totalComments / postsWithComments.length : 0,
},
};
});
}
// Usage with error handling
loadUserProfileWithErrorHandling(999)
.then((profile) => {
console.log('Loaded:', profile.user.name);
})
.catch((error) => {
if (error instanceof DataLoadError) {
console.error(`Load failed at stage "${error.stage}": ${error.message}`);
} else {
console.error('Unexpected error:', error);
}
});
Step 5: Adding Timeout and Retry
Let us add timeout protection and retry logic:
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]);
}
function withRetry<T>(fn: () => Promise<T>, retries: number, delay: number = 1000): Promise<T> {
return fn().catch((error) => {
if (retries > 0) {
console.log(`Retrying... (${retries} attempts left)`);
return new Promise<T>((resolve) => {
setTimeout(() => {
resolve(withRetry(fn, retries - 1, delay));
}, delay);
});
}
throw error;
});
}
function loadUserProfileRobust(userId: number): Promise<UserProfile> {
let loadedUser: User;
// Wrap the entire operation with timeout
return withTimeout(
withRetry(() => fetchUser(userId), 3)
.then((user) => {
loadedUser = user;
return withRetry(() => fetchPostsByUser(user.id), 3);
})
.then((posts) => {
const commentPromises = posts.map((post) =>
withRetry(() => fetchCommentsByPost(post.id), 2)
.then((comments) => ({ post, comments }))
.catch(() => ({ post, comments: [] as Comment[] }))
);
return Promise.all(commentPromises);
})
.then((postsWithComments) => {
const totalComments = postsWithComments.reduce((sum, p) => sum + p.comments.length, 0);
return {
user: loadedUser,
posts: postsWithComments,
stats: {
totalPosts: postsWithComments.length,
totalComments,
averageCommentsPerPost:
postsWithComments.length > 0 ? totalComments / postsWithComments.length : 0,
},
};
}),
10000 // 10 second total timeout
);
}
Complete Solution
Here is the complete, production-ready data loader:
// types.ts
interface User {
id: number;
name: string;
email: string;
username: string;
}
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
interface Comment {
id: number;
postId: number;
name: string;
email: string;
body: string;
}
interface PostWithComments {
post: Post;
comments: Comment[];
}
interface UserProfile {
user: User;
posts: PostWithComments[];
stats: {
totalPosts: number;
totalComments: number;
averageCommentsPerPost: number;
};
}
interface LoadOptions {
timeout?: number;
retries?: number;
includeComments?: boolean;
}
// utils.ts
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]);
}
function withRetry<T>(fn: () => Promise<T>, retries: number, delay: number = 500): Promise<T> {
return fn().catch((error) => {
if (retries > 0) {
return new Promise<T>((resolve) => {
setTimeout(() => resolve(withRetry(fn, retries - 1, delay)), delay);
});
}
throw error;
});
}
// data-loader.ts
class UserDataLoader {
private options: Required<LoadOptions>;
constructor(options: LoadOptions = {}) {
this.options = {
timeout: options.timeout ?? 30000,
retries: options.retries ?? 3,
includeComments: options.includeComments ?? true,
};
}
load(userId: number): Promise<UserProfile> {
let loadedUser: User;
const loadOperation = this.fetchUserWithRetry(userId)
.then((user) => {
loadedUser = user;
return this.fetchPostsWithRetry(user.id);
})
.then((posts) => {
if (!this.options.includeComments) {
return posts.map((post) => ({ post, comments: [] }));
}
return this.loadCommentsForPosts(posts);
})
.then((postsWithComments) => this.buildProfile(loadedUser, postsWithComments));
return withTimeout(loadOperation, this.options.timeout);
}
private fetchUserWithRetry(userId: number): Promise<User> {
return withRetry(() => fetchUser(userId), this.options.retries);
}
private fetchPostsWithRetry(userId: number): Promise<Post[]> {
return withRetry(() => fetchPostsByUser(userId), this.options.retries);
}
private loadCommentsForPosts(posts: Post[]): Promise<PostWithComments[]> {
const commentPromises = posts.map((post) =>
withRetry(() => fetchCommentsByPost(post.id), 2)
.then((comments) => ({ post, comments }))
.catch(() => ({ post, comments: [] as Comment[] }))
);
return Promise.all(commentPromises);
}
private buildProfile(user: User, postsWithComments: PostWithComments[]): UserProfile {
const totalComments = postsWithComments.reduce((sum, p) => sum + p.comments.length, 0);
return {
user,
posts: postsWithComments,
stats: {
totalPosts: postsWithComments.length,
totalComments,
averageCommentsPerPost:
postsWithComments.length > 0 ? totalComments / postsWithComments.length : 0,
},
};
}
}
// Usage
const loader = new UserDataLoader({
timeout: 10000,
retries: 3,
includeComments: true,
});
loader
.load(1)
.then((profile) => {
console.log('=== Profile Loaded ===');
console.log(`User: ${profile.user.name}`);
console.log(`Posts: ${profile.stats.totalPosts}`);
console.log(`Comments: ${profile.stats.totalComments}`);
})
.catch((error) => {
console.error('Failed:', error.message);
});
Exercises
Exercise 1: Add Caching
Extend the UserDataLoader to cache user data and return cached data if available:
class CachedUserDataLoader extends UserDataLoader {
private cache: Map<number, UserProfile> = new Map();
load(userId: number): Promise<UserProfile> {
// Check cache first, then call parent load() if not cached
}
clearCache(userId?: number): void {}
}
Solution
class CachedUserDataLoader extends UserDataLoader {
private cache: Map<number, UserProfile> = new Map();
load(userId: number): Promise<UserProfile> {
const cached = this.cache.get(userId);
if (cached) {
console.log(`Returning cached profile for user ${userId}`);
return Promise.resolve(cached);
}
return super.load(userId).then((profile) => {
this.cache.set(userId, profile);
return profile;
});
}
clearCache(userId?: number): void {
if (userId !== undefined) {
this.cache.delete(userId);
} else {
this.cache.clear();
}
}
}
Exercise 2: Load Multiple Users
Create a function that loads profiles for multiple users in parallel:
function loadMultipleProfiles(userIds: number[]): Promise<Map<number, UserProfile | Error>> {
// Should return a Map where:
// - Successful loads have UserProfile as value
// - Failed loads have Error as value
}
Solution
function loadMultipleProfiles(userIds: number[]): Promise<Map<number, UserProfile | Error>> {
const loader = new UserDataLoader();
const promises = userIds.map((userId) =>
loader
.load(userId)
.then((profile) => ({ userId, result: profile as UserProfile | Error }))
.catch((error) => ({ userId, result: error as Error }))
);
return Promise.all(promises).then((results) => {
const map = new Map<number, UserProfile | Error>();
results.forEach(({ userId, result }) => {
map.set(userId, result);
});
return map;
});
}
// Usage
loadMultipleProfiles([1, 2, 999]).then((results) => {
results.forEach((result, userId) => {
if (result instanceof Error) {
console.log(`User ${userId}: Failed - ${result.message}`);
} else {
console.log(`User ${userId}: ${result.user.name}`);
}
});
});
Exercise 3: Add Progress Callback
Modify the loader to report progress:
interface LoadProgress {
stage: 'user' | 'posts' | 'comments';
completed: number;
total: number;
}
function loadWithProgress(
userId: number,
onProgress: (progress: LoadProgress) => void
): Promise<UserProfile> {}
Solution
function loadWithProgress(
userId: number,
onProgress: (progress: LoadProgress) => void
): Promise<UserProfile> {
let loadedUser: User;
let totalPosts = 0;
let loadedComments = 0;
return fetchUser(userId)
.then((user) => {
loadedUser = user;
onProgress({ stage: 'user', completed: 1, total: 1 });
return fetchPostsByUser(user.id);
})
.then((posts) => {
totalPosts = posts.length;
onProgress({ stage: 'posts', completed: 1, total: 1 });
const commentPromises = posts.map((post) =>
fetchCommentsByPost(post.id).then((comments) => {
loadedComments++;
onProgress({
stage: 'comments',
completed: loadedComments,
total: totalPosts,
});
return { post, comments };
})
);
return Promise.all(commentPromises);
})
.then((postsWithComments) => {
const totalComments = postsWithComments.reduce((sum, p) => sum + p.comments.length, 0);
return {
user: loadedUser,
posts: postsWithComments,
stats: {
totalPosts: postsWithComments.length,
totalComments,
averageCommentsPerPost:
postsWithComments.length > 0 ? totalComments / postsWithComments.length : 0,
},
};
});
}
// Usage
loadWithProgress(1, (progress) => {
console.log(`Loading ${progress.stage}: ${progress.completed}/${progress.total}`);
}).then((profile) => {
console.log('Done!', profile.user.name);
});
Key Takeaways
- Chain
.then()calls for dependent sequential operations - Use
Promise.all()for independent parallel operations - Store intermediate values in outer scope variables when needed across chains
- Handle errors at appropriate levels - some errors should stop everything, others can be recovered
- Add timeout protection for real-world reliability
- Implement retry logic for transient failures
- Organize code into reusable classes and functions
- Consider caching for frequently accessed data
Resources
| Resource | Type | Description |
|---|---|---|
| JSONPlaceholder | API | Free fake API for testing |
| MDN: Using Promises | Tutorial | Comprehensive Promise guide |
| JavaScript.info: Promise API | Tutorial | Promise methods reference |
Module Summary
Congratulations! You have completed the Promises module. You learned:
- Why callbacks lead to callback hell
- How Promises represent future values
- Using
.then(),.catch(), and.finally() - Combining Promises with
all(),race(),allSettled(), andany() - Building real-world data loaders with proper error handling
Next Module
Continue your journey by learning async/await - the modern syntax that makes Promise code even cleaner!