Common Control Flow Patterns

Master the essential patterns that power modern JavaScript applications

Introduction to Control Flow Patterns

Control flow patterns are recurring structures in code that help manage the execution path of a program. They're like the traffic patterns in a city—directing data and operations through different routes based on specific conditions and requirements.

The City Traffic Analogy

Think of your program as a city and control flow as the traffic system. Just as cities have established patterns for managing traffic (one-way streets, intersections with traffic lights, roundabouts), programs have standard patterns for managing execution flow.

  • Conditional statements are like traffic lights, determining whether execution proceeds or stops.
  • Loops are like circular routes or public transit lines, allowing repeated access to the same areas.
  • Function calls are like taking a detour through a specific neighborhood before returning to the main route.
flowchart TD A[Program Start] --> B{Decision Point} B -->|Condition True| C[Path A] B -->|Condition False| D[Path B] C --> E[More Logic] D --> E E --> F{Another Decision} F -->|Loop back| B F -->|Continue| G[Program End]

Understanding these patterns will help you:

Branching Patterns

Branching patterns direct program flow down different paths based on conditions. They're fundamental to creating responsive, intelligent applications.

The Guard Pattern

The guard pattern checks a condition at the beginning of a function and returns early if the condition isn't met. This prevents unnecessary code execution and makes functions more robust.

function processUserData(user) {
    // Guard clauses
    if (!user) {
        console.log('No user provided');
        return null;
    }
    
    if (!user.name) {
        console.log('User has no name');
        return null;
    }
    
    if (!user.age || user.age < 18) {
        console.log('User is under 18');
        return null;
    }
    
    // Main function logic only runs if all guards pass
    const processedData = {
        displayName: user.name.toUpperCase(),
        isAdult: true,
        yearOfBirth: new Date().getFullYear() - user.age
    };
    
    return processedData;
}

Benefits

  • Reduces nesting of code (flat is better than nested)
  • Makes edge cases explicit at the beginning
  • Improves readability and maintainability
  • Prevents "callback hell" or "arrow code" (deeply nested structures)

Real-World Application

Form validation in web applications frequently uses guard patterns to check input validity before processing:

function submitContactForm(formData) {
    if (!formData.email) {
        showError('Email is required');
        return false;
    }
    
    if (!formData.email.includes('@')) {
        showError('Please enter a valid email');
        return false;
    }
    
    if (!formData.message || formData.message.length < 10) {
        showError('Message must be at least 10 characters');
        return false;
    }
    
    // All validation passed, proceed with submission
    sendToServer(formData);
    showSuccess('Message sent successfully!');
    return true;
}

The Switch-Case Pattern

The switch-case pattern handles multiple discrete pathways based on a single value. It's cleaner than multiple if-else statements when comparing the same variable against different values.

function getShippingCost(country) {
    let cost;
    
    switch (country.toLowerCase()) {
        case 'usa':
            cost = 5.99;
            break;
        case 'canada':
            cost = 10.99;
            break;
        case 'mexico':
            cost = 12.99;
            break;
        case 'australia':
            cost = 24.99;
            break;
        default:
            cost = 15.99; // Rest of the world
            break;
    }
    
    return `Shipping cost to ${country}: $${cost}`;
}

Advanced Usage: Fall-Through Cases

The "fall-through" behavior of switch-case can be intentionally used to group cases that should be handled the same way:

function getDayType(day) {
    let type;
    
    switch (day.toLowerCase()) {
        case 'monday':
        case 'tuesday':
        case 'wednesday':
        case 'thursday':
        case 'friday':
            type = 'weekday';
            break;
        case 'saturday':
        case 'sunday':
            type = 'weekend';
            break;
        default:
            type = 'invalid day';
            break;
    }
    
    return type;
}

Modern Alternative: Object Literals

In modern JavaScript, object literals often provide a cleaner alternative to switch-case statements:

function getShippingCost(country) {
    const shippingRates = {
        usa: 5.99,
        canada: 10.99,
        mexico: 12.99,
        australia: 24.99
    };
    
    // Use the rate if it exists, otherwise use default rate
    const cost = shippingRates[country.toLowerCase()] || 15.99;
    
    return `Shipping cost to ${country}: $${cost}`;
}

The State Machine Pattern

The state machine pattern manages complex systems that can be in one of several distinct states, with defined transitions between states. It's especially useful for workflow management, game development, and UI interactions.

Idle Loading Success Error fetch() success error reset() reset()
class FetchMachine {
    constructor() {
        // Possible states
        this.states = {
            IDLE: 'idle',
            LOADING: 'loading',
            SUCCESS: 'success',
            ERROR: 'error'
        };
        
        // Initial state
        this.currentState = this.states.IDLE;
    }
    
    // Transition to a new state
    transition(newState) {
        console.log(`Transitioning from ${this.currentState} to ${newState}`);
        this.currentState = newState;
        
        // Execute side effects for the new state
        switch (newState) {
            case this.states.IDLE:
                console.log('Ready to fetch data');
                break;
            case this.states.LOADING:
                console.log('Loading...');
                break;
            case this.states.SUCCESS:
                console.log('Data loaded successfully!');
                break;
            case this.states.ERROR:
                console.log('Error loading data');
                break;
        }
    }
    
    // Actions that trigger state transitions
    fetch() {
        if (this.currentState !== this.states.IDLE) {
            console.log('Can only fetch in idle state');
            return;
        }
        
        this.transition(this.states.LOADING);
        
        // Simulate network request
        setTimeout(() => {
            // Randomly succeed or fail
            if (Math.random() > 0.3) {
                this.transition(this.states.SUCCESS);
            } else {
                this.transition(this.states.ERROR);
            }
        }, 2000);
    }
    
    reset() {
        this.transition(this.states.IDLE);
    }
}

// Usage
const dataFetcher = new FetchMachine();
dataFetcher.fetch();

Real-World Applications

  • Form wizards: Managing multi-step forms with different validation rules
  • Game development: Character states (idle, walking, running, attacking)
  • UI components: Modal dialogs, dropdown menus, accordions
  • Authentication flows: Managing logged-out, logging-in, authenticated states
  • E-commerce: Order processing states (cart, checkout, payment, confirmation)

Iteration Patterns

Iteration patterns allow us to process collections of data efficiently. Let's explore some of the most useful patterns beyond basic loops.

The Filter-Map-Reduce Pattern

This powerful pattern combines three functional programming operations to transform data collections. It's so common that modern JavaScript provides specialized methods for each operation.

flowchart LR A["[1, 2, 3, 4, 5]"] -->|filter| B["[2, 4]"] B -->|map| C["[4, 8]"] C -->|reduce| D["12"]
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// Traditional approach with for loop
const processNumbers = (nums) => {
    let result = 0;
    
    for (let i = 0; i < nums.length; i++) {
        // Filter: keep only even numbers
        if (nums[i] % 2 === 0) {
            // Map: double each value
            const doubled = nums[i] * 2;
            // Reduce: add to running total
            result += doubled;
        }
    }
    
    return result;
};

console.log(processNumbers(numbers)); // 60

// Modern approach with array methods
const result = numbers
    .filter(num => num % 2 === 0)        // [2, 4, 6, 8, 10]
    .map(num => num * 2)                 // [4, 8, 12, 16, 20]
    .reduce((total, num) => total + num, 0); // 60

console.log(result); // 60

How It Works

  • Filter: Creates a new array containing only elements that pass a test function
  • Map: Creates a new array by transforming each element with a function
  • Reduce: Combines all elements into a single value according to a function

Real-World Example: E-commerce Order Processing

const orders = [
    { id: 1, status: 'completed', total: 85.65, items: 2 },
    { id: 2, status: 'pending', total: 23.82, items: 1 },
    { id: 3, status: 'completed', total: 125.99, items: 3 },
    { id: 4, status: 'cancelled', total: 49.95, items: 2 },
    { id: 5, status: 'completed', total: 56.22, items: 1 }
];

// Process completed orders with more than one item
const highValueOrderTotal = orders
    .filter(order => order.status === 'completed') // Only completed orders
    .filter(order => order.items > 1)              // With multiple items
    .map(order => order.total)                     // Extract total value
    .reduce((sum, total) => sum + total, 0);       // Sum all totals

console.log(`Total value of high-value completed orders: $${highValueOrderTotal.toFixed(2)}`);
// Output: Total value of high-value completed orders: $211.64

The Iterator Pattern

The iterator pattern provides a standardized way to access elements in a collection sequentially without exposing the underlying structure. In JavaScript, this is built into the language with iterables and the for...of loop.

// Simple custom iterable object
const customRange = {
    start: 0,
    end: 5,
    
    // The Symbol.iterator method makes an object iterable
    [Symbol.iterator]() {
        let current = this.start;
        let end = this.end;
        
        // The iterator object with the next method
        return {
            next() {
                return current < end
                    ? { value: current++, done: false }
                    : { done: true };
            }
        };
    }
};

// Use with for...of loop
for (const num of customRange) {
    console.log(num);
}
// Output: 0, 1, 2, 3, 4

// Use with spread operator
console.log([...customRange]); // [0, 1, 2, 3, 4]

Generator Functions for Easy Iterators

Generator functions (with the * symbol) provide a simpler way to create iterators, with the yield keyword marking pause points:

function* rangeGenerator(start, end) {
    for (let i = start; i < end; i++) {
        yield i;
    }
}

// Use the generator
const range = rangeGenerator(0, 5);

console.log(range.next()); // { value: 0, done: false }
console.log(range.next()); // { value: 1, done: false }
console.log([...rangeGenerator(0, 5)]); // [0, 1, 2, 3, 4]

// Can be used in for...of loops
for (const num of rangeGenerator(5, 10)) {
    console.log(num);
}
// Output: 5, 6, 7, 8, 9

Real-World Example: Pagination

Iterators are excellent for managing data pagination, where you need to fetch data in chunks:

function* paginationGenerator(items, pageSize) {
    // Calculate total number of pages
    const totalPages = Math.ceil(items.length / pageSize);
    
    for (let page = 0; page < totalPages; page++) {
        // Calculate slice bounds for current page
        const start = page * pageSize;
        const end = start + pageSize;
        
        // Yield this page's items
        yield {
            page: page + 1,
            totalPages,
            items: items.slice(start, end)
        };
    }
}

// Sample data
const allUsers = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
    { id: 3, name: 'Charlie' },
    { id: 4, name: 'Diana' },
    { id: 5, name: 'Elijah' },
    { id: 6, name: 'Fiona' },
    { id: 7, name: 'George' }
];

// Create paginated view with 2 items per page
const userPages = paginationGenerator(allUsers, 2);

// Display each page
for (const page of userPages) {
    console.log(`Page ${page.page} of ${page.totalPages}:`);
    page.items.forEach(user => console.log(`- ${user.name} (ID: ${user.id})`));
    console.log('---');
}

/* Output:
Page 1 of 4:
- Alice (ID: 1)
- Bob (ID: 2)
---
Page 2 of 4:
- Charlie (ID: 3)
- Diana (ID: 4)
---
Page 3 of 4:
- Elijah (ID: 5)
- Fiona (ID: 6)
---
Page 4 of 4:
- George (ID: 7)
---
*/

The Observer Pattern with Iteration

Combining iteration with the observer pattern allows you to process data streams as they arrive, rather than waiting for the complete collection. This is powerful for handling events, asynchronous data, and large datasets.

sequenceDiagram participant Subject participant Observer1 participant Observer2 Subject->>Subject: State changes Subject->>Observer1: Notify Subject->>Observer2: Notify Observer1->>Observer1: React to change Observer2->>Observer2: React to change
class DataStream {
    constructor() {
        this.observers = [];
        this.buffer = [];
    }
    
    // Add observer
    subscribe(observer) {
        this.observers.push(observer);
        return () => {
            // Return unsubscribe function
            this.observers = this.observers.filter(obs => obs !== observer);
        };
    }
    
    // Notify all observers
    notify(data) {
        this.buffer.push(data);
        this.observers.forEach(observer => observer(data));
    }
    
    // Add data to the stream
    push(data) {
        this.notify(data);
    }
    
    // Create an async iterator for the stream
    async *[Symbol.asyncIterator]() {
        let index = 0;
        
        // First yield any existing buffered data
        while (index < this.buffer.length) {
            yield this.buffer[index++];
        }
        
        // Then wait for new data as it arrives
        while (true) {
            // Create a promise that resolves when new data arrives
            const promise = new Promise(resolve => {
                const unsubscribe = this.subscribe(data => {
                    resolve(data);
                    unsubscribe();
                });
            });
            
            yield await promise;
            index++;
        }
    }
}

// Usage example
const dataStream = new DataStream();

// Subscribe to the stream with observers
const unsubscribe = dataStream.subscribe(data => {
    console.log(`Observer received: ${data}`);
});

// Process the stream with async iteration
(async () => {
    // Start async iterator
    const iterator = dataStream[Symbol.asyncIterator]();
    
    // Push some initial data
    setTimeout(() => dataStream.push('Data 1'), 1000);
    setTimeout(() => dataStream.push('Data 2'), 2000);
    setTimeout(() => dataStream.push('Data 3'), 3000);
    setTimeout(() => unsubscribe(), 3500); // Stop observing after a while
    
    // Process data as it arrives
    for await (const data of dataStream) {
        console.log(`Async iterator received: ${data}`);
        
        // Stop after receiving 3 items
        if (data === 'Data 3') break;
    }
})();

Real-World Applications

  • WebSocket streams: Processing messages as they arrive from a server
  • User interface events: Handling click, scroll, and input events
  • Sensor data: Processing real-time data from IoT devices or browser sensors
  • File uploads: Processing chunks of a large file as they upload

Error Handling Patterns

Error handling patterns help create robust applications that gracefully manage unexpected situations.

The Try-Catch-Finally Pattern

This pattern allows your code to attempt operations that might fail, catch any errors that occur, and ensure cleanup actions always execute.

function readUserData(userId) {
    let connection = null;
    
    try {
        // Setup phase
        console.log(`Opening connection for user ${userId}...`);
        connection = openDatabaseConnection();
        
        // The risky operation
        const userData = connection.query(`SELECT * FROM users WHERE id = ${userId}`);
        
        // Success case
        console.log('Data retrieved successfully');
        return userData;
    } catch (error) {
        // Error handling
        console.error(`Error reading user data: ${error.message}`);
        
        // Provide fallback or rethrow
        if (error.code === 'USER_NOT_FOUND') {
            return { id: userId, name: 'Unknown User', guest: true };
        } else {
            // Rethrow errors we can't handle
            throw error;
        }
    } finally {
        // Cleanup - runs regardless of success or failure
        if (connection) {
            console.log('Closing database connection...');
            connection.close();
        }
    }
}

Best Practices

  • Only catch errors you can handle properly
  • Always clean up resources in finally blocks
  • Be specific about what operations are in the try block
  • Consider creating custom error types for clearer error handling

The Error Propagation Pattern

Sometimes it's better to let errors propagate up the call stack to be handled at a higher level with more context. This pattern involves creating a chain of responsibility for error handling.

sequenceDiagram participant User Interface participant Application Logic participant Data Service participant Database User Interface->>Application Logic: Request data Application Logic->>Data Service: Fetch data Data Service->>Database: Query Database-->>Data Service: Error occurs! Data Service-->>Application Logic: Propagate error Application Logic-->>User Interface: Handle error with context
// Low-level function
function fetchUserFromDatabase(userId) {
    // Simulate database error
    if (!userId || userId < 0) {
        throw new Error('Invalid user ID');
    }
    
    // In real code, this would query a database
    return { id: userId, name: 'User ' + userId };
}

// Mid-level function
function getUserData(userId) {
    try {
        return fetchUserFromDatabase(userId);
    } catch (error) {
        // Add context to the error and rethrow
        throw new Error(`Failed to get user data: ${error.message}`);
    }
}

// High-level function
function displayUserProfile(userId) {
    try {
        const user = getUserData(userId);
        console.log(`Profile for ${user.name} (ID: ${user.id})`);
        return true;
    } catch (error) {
        // Handle the error at the appropriate level
        console.error(`Error displaying profile: ${error.message}`);
        showErrorMessage('Sorry, we couldn\'t load your profile. Please try again later.');
        return false;
    }
}

// Test with valid and invalid IDs
function showErrorMessage(message) {
    console.log(`[ERROR UI]: ${message}`);
}

displayUserProfile(123);  // Should succeed
displayUserProfile(-1);   // Should fail with appropriate error handling

Key Principles

  • Handle errors at the level where you have enough context to make good decisions
  • Add context to errors as they propagate up the call stack
  • Lower-level functions should focus on reporting errors, not handling them
  • User-facing components should translate technical errors into user-friendly messages

The Error Type Pattern

Creating custom error types allows for more specific error handling based on the type of error, not just the error message.

// Define custom error types
class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = 'ValidationError';
    }
}

class AuthenticationError extends Error {
    constructor(message) {
        super(message);
        this.name = 'AuthenticationError';
    }
}

class DatabaseError extends Error {
    constructor(message, code) {
        super(message);
        this.name = 'DatabaseError';
        this.code = code;
    }
}

// Using custom error types
function login(username, password) {
    // Validate inputs
    if (!username || !password) {
        throw new ValidationError('Username and password are required');
    }
    
    try {
        // Authenticate user
        const user = authenticateUser(username, password);
        if (!user) {
            throw new AuthenticationError('Invalid username or password');
        }
        
        // Load user data
        return loadUserProfile(user.id);
    } catch (error) {
        // Specific handling based on error type
        if (error instanceof ValidationError) {
            console.error(`Validation failed: ${error.message}`);
            return { success: false, reason: 'invalid_input' };
        }
        
        if (error instanceof AuthenticationError) {
            console.error(`Authentication failed: ${error.message}`);
            increaseFailedLoginAttempts(username);
            return { success: false, reason: 'auth_failed' };
        }
        
        if (error instanceof DatabaseError) {
            console.error(`Database error (${error.code}): ${error.message}`);
            logDatabaseIssue(error);
            return { success: false, reason: 'system_error' };
        }
        
        // Generic error handling
        console.error(`Unexpected error: ${error.message}`);
        return { success: false, reason: 'unknown_error' };
    }
}

// Simulated support functions
function authenticateUser(username, password) {
    // In a real app, this would check credentials against a database
    if (username === 'admin' && password === 'password123') {
        return { id: 1, username: 'admin', role: 'administrator' };
    }
    return null;
}

function loadUserProfile(userId) {
    // Simulate database error
    if (Math.random() < 0.2) {
        throw new DatabaseError('Connection timeout', 'TIMEOUT');
    }
    
    return {
        success: true,
        profile: { id: userId, name: 'Administrator', email: 'admin@example.com' }
    };
}

function increaseFailedLoginAttempts(username) {
    console.log(`Increased failed login count for ${username}`);
}

function logDatabaseIssue(error) {
    console.log(`Logged database issue: ${error.name} - ${error.code}`);
}

// Test
console.log(login('', ''));  // ValidationError
console.log(login('user', 'wrongpass'));  // AuthenticationError
console.log(login('admin', 'password123'));  // Success or DatabaseError

Real-World Applications

  • API services: Different error types for validation, authorization, rate limiting, etc.
  • File operations: Specific errors for file not found, permission denied, disk full
  • Network requests: Different handling for timeouts, server errors, client errors
  • Form validation: Custom errors for different validation rules

Asynchronous Control Flow Patterns

Modern JavaScript applications heavily rely on asynchronous operations. These patterns help manage asynchronous control flow effectively.

The Promise Chain Pattern

The Promise chain pattern allows for sequential execution of asynchronous operations, with each operation depending on the result of the previous one.

// User registration flow
function registerUser(userData) {
    // Start the promise chain
    return validateUserData(userData)
        .then(validatedData => {
            return checkUserExists(validatedData.email);
        })
        .then(emailAvailable => {
            if (!emailAvailable) {
                throw new Error('Email already in use');
            }
            return hashPassword(userData.password);
        })
        .then(hashedPassword => {
            return saveUser({
                ...userData,
                password: hashedPassword
            });
        })
        .then(userId => {
            return sendWelcomeEmail(userData.email, userId);
        })
        .then(() => {
            return { success: true, message: 'User registered successfully' };
        })
        .catch(error => {
            console.error('Registration failed:', error);
            return { success: false, error: error.message };
        });
}

// Mock implementations of the asynchronous functions
function validateUserData(data) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (!data.email || !data.password) {
                reject(new Error('Email and password are required'));
            } else {
                resolve(data);
            }
        }, 300);
    });
}

function checkUserExists(email) {
    return new Promise(resolve => {
        setTimeout(() => {
            // Simulate database check (fake logic for example)
            const exists = email === 'admin@example.com';
            resolve(!exists);
        }, 500);
    });
}

function hashPassword(password) {
    return new Promise(resolve => {
        setTimeout(() => {
            // In a real app, we'd use a proper hashing function
            resolve(`hashed_${password}`);
        }, 200);
    });
}

function saveUser(userData) {
    return new Promise(resolve => {
        setTimeout(() => {
            // Simulate saving to database
            const userId = Math.floor(Math.random() * 1000);
            console.log(`Saved user with ID: ${userId}`);
            resolve(userId);
        }, 600);
    });
}

function sendWelcomeEmail(email, userId) {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log(`Sent welcome email to ${email} (User ID: ${userId})`);
            resolve();
        }, 400);
    });
}

// Test the registration flow
registerUser({
    name: 'John Doe',
    email: 'john@example.com',
    password: 'secret123'
}).then(result => {
    console.log('Registration result:', result);
});

Parallel Operations with Promise.all

When operations can run independently, we can execute them in parallel for better performance:

function loadDashboardData(userId) {
    // Run these requests in parallel
    return Promise.all([
        fetchUserProfile(userId),
        fetchUserPosts(userId),
        fetchNotifications(userId)
    ])
    .then(([profile, posts, notifications]) => {
        return {
            profile,
            recentPosts: posts.slice(0, 5), // Get 5 most recent posts
            unreadNotifications: notifications.filter(n => !n.read)
        };
    })
    .catch(error => {
        console.error('Failed to load dashboard:', error);
        return { error: 'Failed to load dashboard data' };
    });
}

The Async/Await Pattern

The async/await pattern simplifies asynchronous code by allowing it to be written in a more synchronous style, making it easier to understand and maintain.

Promise Chain

function updateUserProfile(userId, updates) {
  return fetchUser(userId)
    .then(user => {
      const updatedUser = { ...user, ...updates };
      return validateUser(updatedUser);
    })
    .then(validUser => {
      return saveUser(validUser);
    })
    .then(savedUser => {
      return notifyUser(savedUser.id);
    })
    .catch(error => {
      console.error('Update failed:', error);
      throw error;
    });
}

Async/Await

async function updateUserProfile(userId, updates) {
  try {
    const user = await fetchUser(userId);
    const updatedUser = { ...user, ...updates };
    const validUser = await validateUser(updatedUser);
    const savedUser = await saveUser(validUser);
    await notifyUser(savedUser.id);
    return savedUser;
  } catch (error) {
    console.error('Update failed:', error);
    throw error;
  }
}

Real-World Example: Fetching and Processing API Data

async function fetchProductDetails(productId) {
    try {
        // Fetch basic product data
        const product = await fetchProduct(productId);
        
        // Fetch related data in parallel
        const [reviews, relatedProducts] = await Promise.all([
            fetchProductReviews(productId),
            fetchRelatedProducts(product.category)
        ]);
        
        // Process reviews
        const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
        
        // Filter related products (exclude current product)
        const filteredRelated = relatedProducts.filter(p => p.id !== productId);
        
        // Combine all data
        return {
            ...product,
            reviews,
            averageRating,
            relatedProducts: filteredRelated.slice(0, 3) // Top 3 related products
        };
    } catch (error) {
        console.error(`Error fetching product ${productId}:`, error);
        
        // Provide graceful fallback
        return {
            id: productId,
            name: 'Product information unavailable',
            error: error.message
        };
    }
}

// Mock API functions
async function fetchProduct(id) {
    return { id, name: `Product ${id}`, category: 'electronics', price: 99.99 };
}

async function fetchProductReviews(id) {
    return [
        { user: 'Alice', rating: 4, text: 'Great product!' },
        { user: 'Bob', rating: 5, text: 'Excellent value.' },
        { user: 'Charlie', rating: 3, text: 'Good but not great.' }
    ];
}

async function fetchRelatedProducts(category) {
    return [
        { id: 101, name: 'Related Product 1', category },
        { id: 102, name: 'Related Product 2', category },
        { id: 103, name: 'Related Product 3', category },
        { id: 104, name: 'Related Product 4', category }
    ];
}

Best Practices

  • Always use try/catch blocks with await to handle errors properly
  • Use Promise.all for parallel operations that don't depend on each other
  • Remember that an async function always returns a promise
  • Avoid mixing promise chains and async/await in the same function

The Async Iterator Pattern

The async iterator pattern combines asynchronous operations with iteration, allowing you to process items sequentially even when retrieving them involves asynchronous operations.

// Asynchronous data source (e.g., paginated API)
async function* fetchAllUsers() {
    let page = 1;
    let hasMoreData = true;
    
    while (hasMoreData) {
        console.log(`Fetching page ${page}...`);
        
        // Simulate API call with pagination
        const response = await fetchUserPage(page);
        
        // Yield each user individually
        for (const user of response.users) {
            yield user;
        }
        
        // Check if we need to fetch more pages
        hasMoreData = response.hasNextPage;
        page++;
    }
}

// Mock paginated API
async function fetchUserPage(page) {
    // Simulate network delay
    await new Promise(resolve => setTimeout(resolve, 500));
    
    // Generate some fake users
    const users = Array.from({ length: 3 }, (_, i) => {
        const id = (page - 1) * 3 + i + 1;
        return { id, name: `User ${id}` };
    });
    
    // Only return 3 pages in this example
    return {
        users,
        hasNextPage: page < 3
    };
}

// Using the async iterator
async function processAllUsers() {
    // Create an array to store processed users
    const processedUsers = [];
    
    // Process users one by one as they come in
    for await (const user of fetchAllUsers()) {
        console.log(`Processing user: ${user.name}`);
        
        // Simulate some processing
        const processed = await processUser(user);
        processedUsers.push(processed);
    }
    
    console.log('All users processed!');
    return processedUsers;
}

// Mock processing function
async function processUser(user) {
    // Simulate processing delay
    await new Promise(resolve => setTimeout(resolve, 200));
    
    return {
        ...user,
        status: 'processed',
        timestamp: new Date().toISOString()
    };
}

// Run the example
processAllUsers().then(result => {
    console.log('Final result:', result);
});

Real-World Applications

  • Paginated APIs: Processing large datasets that come in pages
  • File processing: Reading and processing large files in chunks
  • Database cursors: Iterating through large query results
  • Event streams: Processing events as they arrive

Practical Exercise: Building a Task Manager

Let's apply several of the control flow patterns we've learned to build a simple task management system. This exercise will integrate:

// Define task states
const TaskState = {
    TODO: 'todo',
    IN_PROGRESS: 'in_progress',
    REVIEW: 'review',
    DONE: 'done'
};

// Define possible transitions
const allowedTransitions = {
    [TaskState.TODO]: [TaskState.IN_PROGRESS],
    [TaskState.IN_PROGRESS]: [TaskState.REVIEW, TaskState.TODO],
    [TaskState.REVIEW]: [TaskState.DONE, TaskState.IN_PROGRESS],
    [TaskState.DONE]: [TaskState.TODO] // Allow reopening
};

// Custom errors
class TaskError extends Error {
    constructor(message) {
        super(message);
        this.name = 'TaskError';
    }
}

class TaskManager {
    constructor() {
        this.tasks = new Map();
        this.observers = [];
        this.lastId = 0;
    }
    
    // Observer pattern: Subscribe to task updates
    subscribe(observer) {
        this.observers.push(observer);
        return () => {
            this.observers = this.observers.filter(obs => obs !== observer);
        };
    }
    
    // Notify all observers
    notifyObservers(eventType, taskData) {
        this.observers.forEach(observer => {
            try {
                observer(eventType, taskData);
            } catch (error) {
                console.error('Error in observer:', error);
                // Continue with other observers even if one fails
            }
        });
    }
    
    // Create a new task
    createTask(title, description) {
        try {
            // Validate input
            if (!title) {
                throw new TaskError('Task title is required');
            }
            
            const id = ++this.lastId;
            const task = {
                id,
                title,
                description: description || '',
                state: TaskState.TODO,
                createdAt: new Date(),
                updatedAt: new Date(),
                history: [{
                    state: TaskState.TODO,
                    timestamp: new Date()
                }]
            };
            
            // Store task
            this.tasks.set(id, task);
            
            // Notify observers
            this.notifyObservers('task-created', { ...task });
            
            return id;
        } catch (error) {
            if (error instanceof TaskError) {
                console.error(`Failed to create task: ${error.message}`);
            } else {
                console.error('Unexpected error creating task:', error);
            }
            throw error;
        }
    }
    
    // Get a task by ID
    getTask(id) {
        const task = this.tasks.get(id);
        if (!task) {
            throw new TaskError(`Task with ID ${id} not found`);
        }
        return { ...task }; // Return a copy to prevent direct mutation
    }
    
    // State machine pattern: Transition task state
    async transitionTask(id, newState, comment) {
        try {
            const task = this.tasks.get(id);
            
            // Guard clauses
            if (!task) {
                throw new TaskError(`Task with ID ${id} not found`);
            }
            
            if (newState === task.state) {
                console.log(`Task ${id} is already in ${newState} state`);
                return task.state;
            }
            
            if (!allowedTransitions[task.state].includes(newState)) {
                throw new TaskError(`Cannot transition from ${task.state} to ${newState}`);
            }
            
            // Simulate async validation or side effects
            await this.validateTransition(task, newState);
            
            // Update task state
            const oldState = task.state;
            task.state = newState;
            task.updatedAt = new Date();
            
            // Record in history
            task.history.push({
                state: newState,
                timestamp: new Date(),
                comment: comment || undefined
            });
            
            // Notify observers
            this.notifyObservers('task-updated', {
                id: task.id,
                oldState,
                newState,
                updatedAt: task.updatedAt
            });
            
            return task.state;
        } catch (error) {
            console.error(`Failed to transition task: ${error.message}`);
            throw error;
        }
    }
    
    // Mock async validation
    async validateTransition(task, newState) {
        // Simulate network delay
        await new Promise(resolve => setTimeout(resolve, 300));
        
        // Example validation rule
        if (newState === TaskState.DONE && task.title.includes('urgent')) {
            throw new TaskError('Urgent tasks require review before completion');
        }
        
        return true;
    }
    
    // Get tasks filtered by state
    getTasksByState(state) {
        const result = [];
        
        // Iterator pattern with filtering
        for (const task of this.tasks.values()) {
            if (!state || task.state === state) {
                result.push({ ...task });
            }
        }
        
        return result;
    }
    
    // Create an async generator for tasks
    async *taskGenerator(filter = () => true) {
        for (const task of this.tasks.values()) {
            if (filter(task)) {
                // Simulate async processing
                await new Promise(resolve => setTimeout(resolve, 100));
                yield { ...task };
            }
        }
    }
}

// Example usage
async function runExample() {
    const taskManager = new TaskManager();
    
    // Subscribe to task events
    const unsubscribe = taskManager.subscribe((eventType, data) => {
        console.log(`Event: ${eventType}`, data);
    });
    
    try {
        // Create some tasks
        const task1 = taskManager.createTask('Implement login feature', 'Add user authentication');
        const task2 = taskManager.createTask('Fix navigation bug', 'Menu disappears on mobile');
        const task3 = taskManager.createTask('Update documentation', 'Update API docs with new endpoints');
        
        // Start working on a task
        await taskManager.transitionTask(task1, TaskState.IN_PROGRESS, 'John is working on this');
        
        // Try an invalid transition
        try {
            await taskManager.transitionTask(task2, TaskState.DONE, 'Skipping steps');
        } catch (error) {
            console.log('Expected error:', error.message);
        }
        
        // Move task to review
        await taskManager.transitionTask(task1, TaskState.REVIEW, 'Ready for code review');
        
        // Complete the task
        await taskManager.transitionTask(task1, TaskState.DONE, 'All tests passing');
        
        // Use the task generator to process tasks asynchronously
        console.log('\nProcessing all tasks:');
        for await (const task of taskManager.taskGenerator()) {
            console.log(`Processing task: ${task.id} - ${task.title} (${task.state})`);
        }
        
        // Filter for only completed tasks
        console.log('\nCompleted tasks:');
        for await (const task of taskManager.taskGenerator(task => task.state === TaskState.DONE)) {
            console.log(`Completed: ${task.title}`);
        }
        
    } catch (error) {
        console.error('Error in task management:', error);
    } finally {
        // Clean up
        unsubscribe();
    }
}

runExample();

Challenge Extensions

  1. Add a timeout feature that automatically flags tasks as "stale" if they remain in the same state for too long
  2. Implement a task dependency system, where tasks can depend on other tasks being completed first
  3. Add user assignments and permission checks for task transitions
  4. Create a simple persistence layer that saves tasks to localStorage or a mock API

Summary of Control Flow Patterns

Branching Patterns

  • Guard Pattern: Check conditions at the beginning and return early
  • Switch-Case Pattern: Handle multiple discrete pathways based on a single value
  • State Machine Pattern: Manage complex systems with distinct states and transitions

Iteration Patterns

  • Filter-Map-Reduce Pattern: Transform data collections through a series of operations
  • Iterator Pattern: Access elements sequentially without exposing underlying structure
  • Observer Pattern with Iteration: Process data streams as they arrive

Error Handling Patterns

  • Try-Catch-Finally Pattern: Attempt operations, catch errors, ensure cleanup
  • Error Propagation Pattern: Let errors propagate up the call stack for handling
  • Error Type Pattern: Create custom error types for specific handling

Asynchronous Patterns

  • Promise Chain Pattern: Execute asynchronous operations sequentially
  • Async/Await Pattern: Write asynchronous code in a synchronous style
  • Async Iterator Pattern: Process items sequentially when retrieval is asynchronous

Key Takeaways

  • Control flow patterns help organize code in a predictable, maintainable way
  • Understanding common patterns will help you recognize them in other code and implement them efficiently
  • Different patterns serve different purposes; choose the appropriate pattern for your specific scenario
  • Modern JavaScript provides built-in support for many control flow patterns
  • Combining patterns creates powerful, elegant solutions to complex problems

Further Learning

Practice Activities

  1. State Machine Implementation: Create a simple state machine for a traffic light system with different states (red, yellow, green) and appropriate transitions.
  2. Promise Chain Application: Implement a multi-step process (e.g., image upload, validation, processing, saving) using promise chains.
  3. Custom Error Hierarchy: Design a hierarchy of custom error types for a web application, covering different categories of errors (validation, network, authentication, etc.).
  4. Async Iterator Usage: Use the async iterator pattern to process items from a paginated API endpoint.
  5. Guard Pattern Refactoring: Take an existing function with deeply nested conditions and refactor it using the guard pattern.