JavaScript Promises

A Better Way to Handle Asynchronous Operations

Introduction to Promises

Remember the callback hell we discussed? Promises are JavaScript's elegant solution to this problem. Think of a Promise as a restaurant receipt - when you order food, you get a receipt (promise) that represents your future meal. The meal might arrive successfully, or there might be a problem in the kitchen.

What is a Promise?

A Promise is an object representing the eventual completion or failure of an asynchronous operation. It can be in one of three states:

graph LR A[Pending] -->|Fulfilled| B[Resolved] A -->|Rejected| C[Rejected] B --> D[.then handler] C --> E[.catch handler]

Creating a Promise

Let's start with the basics of creating and using promises:

// Creating a simple promise
const myPromise = new Promise((resolve, reject) => {
    // Asynchronous operation here
    setTimeout(() => {
        const success = true; // Simulate success/failure
        
        if (success) {
            resolve("Operation successful!");
        } else {
            reject("Operation failed!");
        }
    }, 1000);
});

// Using the promise
myPromise
    .then(result => {
        console.log(result); // "Operation successful!"
    })
    .catch(error => {
        console.error(error);
    });

Promise Anatomy

Let's break down the components of a Promise:

// 1. The Promise constructor
const promise = new Promise((resolve, reject) => {
    // 2. Executor function with resolve and reject parameters
    
    // 3. Your async operation
    setTimeout(() => {
        const randomNumber = Math.random();
        
        if (randomNumber > 0.5) {
            // 4. Call resolve for success
            resolve(randomNumber);
        } else {
            // 5. Call reject for failure
            reject(new Error('Number too small'));
        }
    }, 1000);
});

// 6. Handling the promise
promise
    .then(value => {
        // 7. Success handler
        console.log('Success:', value);
    })
    .catch(error => {
        // 8. Error handler
        console.error('Error:', error.message);
    })
    .finally(() => {
        // 9. Cleanup (always runs)
        console.log('Operation complete');
    });

Converting Callbacks to Promises

Let's transform our callback-based functions into promise-based ones:

Before (Callback)

function fetchUserDataCallback(userId, callback) {
    setTimeout(() => {
        if (!userId) {
            callback(new Error('Invalid user ID'), null);
            return;
        }
        callback(null, { id: userId, name: 'John Doe' });
    }, 1000);
}

After (Promise)

function fetchUserDataPromise(userId) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (!userId) {
                reject(new Error('Invalid user ID'));
                return;
            }
            resolve({ id: userId, name: 'John Doe' });
        }, 1000);
    });
}

// Usage is much cleaner
fetchUserDataPromise(123)
    .then(user => console.log('User:', user))
    .catch(error => console.error('Error:', error));

Promise Chaining

One of the most powerful features of Promises is chaining. Each .then() returns a new Promise, allowing us to chain operations:

// Sequential operations with promise chaining
function fetchUser(id) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ id, name: 'Alice' });
        }, 1000);
    });
}

function fetchPosts(userId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve([
                { id: 1, userId, title: 'Post 1' },
                { id: 2, userId, title: 'Post 2' }
            ]);
        }, 1000);
    });
}

function fetchComments(postId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve([
                { id: 1, postId, text: 'Great post!' },
                { id: 2, postId, text: 'Thanks for sharing!' }
            ]);
        }, 1000);
    });
}

// Clean promise chain
fetchUser(1)
    .then(user => {
        console.log('User:', user);
        return fetchPosts(user.id);
    })
    .then(posts => {
        console.log('Posts:', posts);
        return fetchComments(posts[0].id);
    })
    .then(comments => {
        console.log('Comments:', comments);
    })
    .catch(error => {
        console.error('Error in chain:', error);
    });

Compare this to the callback hell version - much more readable!

Promise Static Methods

JavaScript provides several useful static methods on the Promise object:

Promise.resolve() and Promise.reject()

// Create immediately resolved/rejected promises
const resolvedPromise = Promise.resolve('Instant success!');
const rejectedPromise = Promise.reject(new Error('Instant failure!'));

resolvedPromise.then(value => console.log(value));
rejectedPromise.catch(error => console.error(error));

Promise.all()

// Wait for all promises to complete
const promise1 = fetchUser(1);
const promise2 = fetchPosts(1);
const promise3 = fetchComments(1);

Promise.all([promise1, promise2, promise3])
    .then(([user, posts, comments]) => {
        console.log('All data loaded:', { user, posts, comments });
    })
    .catch(error => {
        console.error('One or more promises failed:', error);
    });

Promise.race()

// Resolves/rejects with the first settled promise
const slowPromise = new Promise(resolve => 
    setTimeout(() => resolve('Slow'), 3000)
);

const fastPromise = new Promise(resolve => 
    setTimeout(() => resolve('Fast'), 1000)
);

Promise.race([slowPromise, fastPromise])
    .then(winner => console.log('Winner:', winner)); // 'Fast'

Promise.allSettled()

// Wait for all promises to settle (resolve or reject)
const promises = [
    Promise.resolve('Success'),
    Promise.reject(new Error('Failure')),
    Promise.resolve('Another success')
];

Promise.allSettled(promises)
    .then(results => {
        results.forEach(result => {
            if (result.status === 'fulfilled') {
                console.log('Success:', result.value);
            } else {
                console.log('Failed:', result.reason);
            }
        });
    });

Real-World Examples

Example 1: API Request with Retry Logic

function fetchWithRetry(url, maxRetries = 3) {
    return new Promise((resolve, reject) => {
        function attempt(retriesLeft) {
            fetch(url)
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP error! status: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => resolve(data))
                .catch(error => {
                    if (retriesLeft === 0) {
                        reject(error);
                    } else {
                        console.log(`Retrying... ${retriesLeft} attempts left`);
                        setTimeout(() => attempt(retriesLeft - 1), 1000);
                    }
                });
        }
        
        attempt(maxRetries);
    });
}

// Usage
fetchWithRetry('https://api.example.com/data')
    .then(data => console.log('Success:', data))
    .catch(error => console.error('Failed after retries:', error));

Example 2: Loading Multiple Resources

function loadUserDashboard(userId) {
    // Load multiple resources in parallel
    const userPromise = fetch(`/api/users/${userId}`).then(r => r.json());
    const settingsPromise = fetch(`/api/settings/${userId}`).then(r => r.json());
    const notificationsPromise = fetch(`/api/notifications/${userId}`).then(r => r.json());
    
    return Promise.all([userPromise, settingsPromise, notificationsPromise])
        .then(([user, settings, notifications]) => {
            return {
                user,
                settings,
                notifications,
                loadedAt: new Date()
            };
        });
}

// Usage
loadUserDashboard(123)
    .then(dashboard => {
        console.log('Dashboard loaded:', dashboard);
        // Update UI with dashboard data
    })
    .catch(error => {
        console.error('Failed to load dashboard:', error);
        // Show error message to user
    });

Common Promise Patterns

Pattern 1: Promisifying Callbacks

// Convert any callback-based function to promise-based
function promisify(callbackBasedFunction) {
    return function(...args) {
        return new Promise((resolve, reject) => {
            callbackBasedFunction(...args, (error, result) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(result);
                }
            });
        });
    };
}

// Example usage
const readFileAsync = promisify(fs.readFile);
readFileAsync('file.txt', 'utf8')
    .then(content => console.log(content))
    .catch(error => console.error(error));

Pattern 2: Sequential Execution

// Execute promises in sequence
async function executeSequentially(tasks) {
    const results = [];
    
    for (const task of tasks) {
        try {
            const result = await task();
            results.push(result);
        } catch (error) {
            console.error('Task failed:', error);
            results.push(null);
        }
    }
    
    return results;
}

// Usage
const tasks = [
    () => fetchUser(1),
    () => fetchPosts(1),
    () => fetchComments(1)
];

executeSequentially(tasks)
    .then(results => console.log('All tasks complete:', results));

Error Handling Best Practices

// Always catch errors at the end of promise chains
fetchUser(1)
    .then(user => fetchPosts(user.id))
    .then(posts => fetchComments(posts[0].id))
    .then(comments => {
        // Process comments
    })
    .catch(error => {
        // Handle any error in the chain
        console.error('Operation failed:', error);
        
        // You can also check error types
        if (error instanceof NetworkError) {
            showOfflineMessage();
        } else if (error instanceof ValidationError) {
            showValidationErrors(error.errors);
        } else {
            showGenericError();
        }
    });

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

class ValidationError extends Error {
    constructor(errors) {
        super('Validation failed');
        this.name = 'ValidationError';
        this.errors = errors;
    }
}

Practice Exercises

Exercise 1: Promise-based Timer

Create a promise-based delay function:

function delay(ms) {
    // Your code here
    // Should return a promise that resolves after ms milliseconds
}

// Test your function
delay(2000)
    .then(() => console.log('2 seconds have passed'));

Exercise 2: Promise Chain Calculator

Create a calculator using promise chaining:

function calculate(initialValue) {
    // Return an object with methods that return promises
    // Each method should modify the value and return the calculator
}

// Usage example
calculate(5)
    .add(3)
    .multiply(2)
    .subtract(4)
    .divide(2)
    .getResult()
    .then(result => console.log(result)); // Should output 6

Key Takeaways

Looking Ahead

Next, we'll explore async/await syntax, which makes working with Promises even more elegant by allowing us to write asynchronous code that looks synchronous.

// Sneak peek at async/await
async function loadDashboard() {
    try {
        const user = await fetchUser(1);
        const posts = await fetchPosts(user.id);
        const comments = await fetchComments(posts[0].id);
        
        return { user, posts, comments };
    } catch (error) {
        console.error('Error:', error);
    }
}

Additional Resources