Y
Published on

Mastering Promises in JavaScript

Authors
  • avatar
    Name
    Yinhuan Yuan
    Twitter
Introduction

In the world of asynchronous programming, Promises have become an indispensable tool for JavaScript developers. They provide a clean, intuitive way to work with asynchronous operations, making our code more readable and easier to reason about. In this blog post, we'll dive deep into Promises, exploring what they are, how to use them, and some advanced techniques.

What is a Promise?

A Promise in JavaScript is an object representing the eventual completion or failure of an asynchronous operation. It's a proxy for a value that may not be known when the promise is created. Promises allow you to attach callbacks to handle the success or failure of an asynchronous action, rather than passing callbacks into a function.

The Anatomy of a Promise

A Promise can be in one of three states:

  1. Pending: Initial state, neither fulfilled nor rejected.
  2. Fulfilled: The operation completed successfully.
  3. Rejected: The operation failed.

Here's the basic syntax for creating a Promise:

const myPromise = new Promise((resolve, reject) => {
  // Asynchronous operation here
  if (/* operation successful */) {
    resolve(result);
  } else {
    reject(error);
  }
});

Using Promises

To use a Promise, we chain .then(), .catch(), and .finally() methods to it:

myPromise
  .then((result) => {
    console.log(result)
  })
  .catch((error) => {
    console.error(error)
  })
  .finally(() => {
    console.log('Promise settled')
  })

Creating a Real-World Promise

Let's create a Promise that simulates fetching data from a server:

function fetchData(url) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Simulating network request
      if (url === 'https://api.example.com/data') {
        resolve({ id: 1, name: 'John Doe' })
      } else {
        reject(new Error('404 Not Found'))
      }
    }, 2000)
  })
}

fetchData('https://api.example.com/data')
  .then((data) => console.log(data))
  .catch((error) => console.error(error))

Promise Chaining

One of the powerful features of Promises is the ability to chain them:

fetchData('https://api.example.com/data')
  .then((user) => fetchData(`https://api.example.com/posts/${user.id}`))
  .then((posts) => console.log(posts))
  .catch((error) => console.error(error))

Promise.all()

When you need to run multiple Promises concurrently and wait for all of them to complete, use Promise.all():

const promise1 = fetchData('https://api.example.com/data1')
const promise2 = fetchData('https://api.example.com/data2')
const promise3 = fetchData('https://api.example.com/data3')

Promise.all([promise1, promise2, promise3])
  .then((results) => console.log(results))
  .catch((error) => console.error(error))

Promise.race()

If you want to respond as soon as any Promise in a group settles, use Promise.race():

const promise1 = new Promise((resolve) => setTimeout(() => resolve('one'), 1000))
const promise2 = new Promise((resolve) => setTimeout(() => resolve('two'), 2000))

Promise.race([promise1, promise2])
  .then((result) => console.log(result)) // Logs 'one'
  .catch((error) => console.error(error))

Promise.allSettled()

Introduced in ES2020, Promise.allSettled() is useful when you have multiple asynchronous tasks that are not dependent on one another to complete. It returns a promise that resolves after all of the given promises have either fulfilled or rejected, with an array of objects that each describes the outcome of each promise.

const promises = [
  fetch('https://api.example.com/endpoint-1'),
  fetch('https://api.example.com/endpoint-2'),
  fetch('https://api.example.com/endpoint-3-that-doesnt-exist'),
]

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

This method is particularly useful when you want to wait for multiple operations to complete, regardless of whether they succeed or fail.

Promise.any()

Promise.any() is the most recent addition to the Promise API, introduced in ES2021. It takes an iterable of Promise objects and returns a single promise. This returned promise fulfills when any of the input's promises fulfills, with this first fulfillment value. It rejects when all of the input's promises reject.

const promises = [
  fetch('https://api.example.com/endpoint-1').then(() => 'endpoint-1'),
  fetch('https://api.example.com/endpoint-2').then(() => 'endpoint-2'),
  fetch('https://api.example.com/endpoint-3').then(() => 'endpoint-3'),
]

Promise.any(promises)
  .then((first) => console.log('First to fulfill:', first))
  .catch((error) => {
    console.log('All promises were rejected')
    console.log(error.errors) // AggregateError
  })

Promise.any() is useful in scenarios where you want to receive the result of the first successful operation from a set of promises, ignoring any rejections unless all promises are rejected.

Comparing Promise Combination Methods

To summarize the differences between the Promise combination methods:

  • Promise.all(): Fulfills when all promises fulfill, rejects if any promise rejects.
  • Promise.race(): Settles as soon as any promise settles (fulfills or rejects).
  • Promise.allSettled(): Fulfills with an array of results when all promises settle.
  • Promise.any(): Fulfills as soon as any promise fulfills, rejects if all promises reject.

Here's a quick comparison:

const p1 = new Promise((resolve) => setTimeout(() => resolve('one'), 1000))
const p2 = new Promise((_, reject) => setTimeout(() => reject('two'), 2000))
const p3 = new Promise((resolve) => setTimeout(() => resolve('three'), 3000))

Promise.all([p1, p2, p3]).then(console.log).catch(console.error) // Logs: "two" (after 2 seconds)

Promise.race([p1, p2, p3]).then(console.log).catch(console.error) // Logs: "one" (after 1 second)

Promise.allSettled([p1, p2, p3]).then(console.log) // Logs array of results (after 3 seconds)

Promise.any([p1, p2, p3]).then(console.log).catch(console.error) // Logs: "one" (after 1 second)

Async/Await: Syntactic Sugar for Promises

ES2017 introduced async/await, which is syntactic sugar for working with Promises:

async function fetchUserAndPosts() {
  try {
    const user = await fetchData('https://api.example.com/user')
    const posts = await fetchData(`https://api.example.com/posts/${user.id}`)
    console.log(posts)
  } catch (error) {
    console.error(error)
  }
}

fetchUserAndPosts()

Async Iteration

Async iteration, introduced in ES2018, allows you to create and consume asynchronous data streams in a way that looks and feels like synchronous iteration. This is particularly useful when dealing with asynchronous data sources like network requests or file system operations.

The for-await-of Loop

The for-await-of loop is the cornerstone of async iteration. It allows you to iterate over asynchronous iterable objects:

async function* asyncGenerator() {
  yield await Promise.resolve(1)
  yield await Promise.resolve(2)
  yield await Promise.resolve(3)
}

;(async () => {
  for await (const value of asyncGenerator()) {
    console.log(value)
  }
})()
// Logs:
// 1
// 2
// 3
Async Iterables and Async Iterators

An async iterable is an object that implements the Symbol.asyncIterator method. This method returns an async iterator, which has a next() method that returns a Promise for an iterator result object.

const asyncIterable = {
  async *[Symbol.asyncIterator]() {
    yield 'hello'
    yield 'async'
    yield 'world'
  },
}

;(async () => {
  for await (const x of asyncIterable) {
    console.log(x)
  }
})()
// Logs:
// hello
// async
// world
Real-World Example: Reading a File Line by Line

Here's an example of how you might use async iteration to read a file line by line:

const fs = require('fs').promises
const readline = require('readline')

async function* readLines(path) {
  const fileStream = await fs.open(path, 'r')
  try {
    const rl = readline.createInterface({
      input: fileStream.createReadStream(),
      crlfDelay: Infinity,
    })
    for await (const line of rl) {
      yield line
    }
  } finally {
    await fileStream.close()
  }
}

;(async () => {
  for await (const line of readLines('./example.txt')) {
    console.log('Line:', line)
  }
})()

Top-Level Await

Top-level await, introduced in ES2022, allows you to use the await keyword outside of async functions in the top-level scope of modules. This feature simplifies the use of Promises in modules and eliminates some of the need for immediately invoked async function expressions (IIAFEs).

Basic Usage

With top-level await, you can write asynchronous initialization code at the module level:

// module.js
const response = await fetch('https://api.example.com/data')
export const data = await response.json()
Dynamic Module Loading

Top-level await is particularly useful for dynamically loading modules:

const strings = await import(`./strings_${navigator.language}.js`)
console.log(strings.hello)
Conditional Module Loading

You can use top-level await to conditionally load modules or perform other async operations before exporting:

let jQuery
if (window.jQuery) {
  jQuery = window.jQuery
} else {
  jQuery = await import('https://cdn.jsdelivr.net/npm/jquery@3/dist/jquery.min.js')
}
export { jQuery }
Considerations

While top-level await is powerful, it's important to use it judiciously:

  1. It can delay the execution of a module and its dependents.
  2. Circular dependencies with top-level await can lead to deadlocks.
  3. It's only available in modules, not in regular scripts.

Error Handling in Promises

Proper error handling is crucial when working with Promises. Always include .catch() blocks or use try/catch with async/await:

fetchData('https://api.example.com/data')
  .then((result) => {
    // Handle success
  })
  .catch((error) => {
    if (error.name === 'NetworkError') {
      console.error('Network error occurred')
    } else {
      console.error('An unexpected error occurred', error)
    }
  })

Conclusion

Promises have revolutionized asynchronous programming in JavaScript, making it easier to write, read, and reason about asynchronous code. They form the foundation for more advanced patterns and are a prerequisite for using the async/await syntax. By mastering Promises, you'll be well-equipped to handle complex asynchronous operations in your JavaScript applications.

Remember, while Promises are powerful, they're not always the best tool for every job. For simple callbacks or synchronous operations, traditional methods might be more appropriate. As with all programming patterns, use Promises where they add clarity and simplify your code.

As we've seen, the Promise API in JavaScript has evolved to include powerful methods like Promise.allSettled() and Promise.any(), providing developers with even more flexibility in handling complex asynchronous scenarios. By understanding and leveraging these different Promise combination methods, you can write more robust and efficient asynchronous code that handles a wide variety of use cases.

The introduction of async iteration and top-level await has further expanded the capabilities of asynchronous JavaScript. Async iteration provides a powerful way to work with asynchronous data streams, while top-level await simplifies asynchronous module initialization and dynamic imports. These features, combined with Promises and async/await syntax, give developers a robust toolkit for handling complex asynchronous scenarios in modern JavaScript applications.

As you continue to work with asynchronous JavaScript, remember that these advanced features are tools to make your code more readable and maintainable. Always consider the specific needs of your project and the environments where your code will run when deciding which features to use.

Happy coding, and may all your asynchronous operations be smooth and efficient!