From Zero to AI

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:

  1. Fetches a user by ID
  2. Gets all posts for that user
  3. Retrieves comments for each post
  4. 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

  1. Chain .then() calls for dependent sequential operations
  2. Use Promise.all() for independent parallel operations
  3. Store intermediate values in outer scope variables when needed across chains
  4. Handle errors at appropriate levels - some errors should stop everything, others can be recovered
  5. Add timeout protection for real-world reliability
  6. Implement retry logic for transient failures
  7. Organize code into reusable classes and functions
  8. 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(), and any()
  • 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!

Continue to Module 3: Async/Await