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.
Understanding these patterns will help you:
- Write more readable and maintainable code
- Solve common programming problems efficiently
- Communicate more effectively with other developers
- Recognize patterns in others' code
- Debug your applications with greater insight
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.
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.
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.
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.
// 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:
- The state machine pattern for task state management
- The observer pattern for task updates
- Error handling patterns for robust operation
- Asynchronous patterns for I/O operations
// 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
- Add a timeout feature that automatically flags tasks as "stale" if they remain in the same state for too long
- Implement a task dependency system, where tasks can depend on other tasks being completed first
- Add user assignments and permission checks for task transitions
- 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
- State Machine Implementation: Create a simple state machine for a traffic light system with different states (red, yellow, green) and appropriate transitions.
- Promise Chain Application: Implement a multi-step process (e.g., image upload, validation, processing, saving) using promise chains.
- Custom Error Hierarchy: Design a hierarchy of custom error types for a web application, covering different categories of errors (validation, network, authentication, etc.).
- Async Iterator Usage: Use the async iterator pattern to process items from a paginated API endpoint.
- Guard Pattern Refactoring: Take an existing function with deeply nested conditions and refactor it using the guard pattern.