promises.md 11 KB

Promises

Passing data to callbacks

In the previous set of notes, we used callbacks as a way to run some code when something happens. However, we often want to know what happened. In this case, we can pass the result of an asychronous action as an argument to the callback function.

We'll look this week at some of the asynchronous functions that make up Node.js's filesystem API. The full set of functions can be found in the documentation for the fs module. There are quite a lot, so we'll look at four of the most useful ones:

  • fs.readFile(), used to read the entire contents of a file
  • fs.writeFile(), used to set the entire contents of a file
  • fs.readdir(), used to list the files in a directory
  • fs.stat(), used to get metadata about a file (e.g. its size, when it was last modified, etc.)

To illustrate the first two of these, here is an example program that writes to a file and then reads its contents:

// Import Node.js's 'fs' module
const fs = require('fs')

// Write 'hello, world' to the file
fs.writeFile('something.txt', 'hello, world', (err) => {
  if (err) throw err

  // Now read the file; it should contain 'hello, world' now.
  // 'utf8' indicates that the file is a text file encoded in UTF-8.
  fs.readFile('something.txt', 'utf8', (err, data) => {
    if (err) throw err

    // Prints 'hello, world'
    console.log(data)
  })
})

Callback hell — nesting

You may have noticed that the asynchronous programs above require significantly more code than their synchronous counterparts. For example, we would write something like this instead in a synchronous language:

writeFile('something.txt', 'hello, world')
console.log(readFile('something.txt', 'utf8'))

The general observation that using callbacks tends to make programs more complicated is called "callback hell".

One aspect of callback hell is that, as we saw last time, asynchronous tasks that happen in sequence must be nested inside the callbacks of the previous tasks. For example, here is a program that computes the total size of 3 files:

fs.stat('a.txt', (err, statsA) => {
  if (err) throw err

  fs.stat('b.txt', (err, statsB) => {
    if (err) throw err

    fs.stat('c.txt', (err, statsC) => {
      if (err) throw err

      const totalSize = statsA.size + statsB.size + statsC.size
      console.log(`Total size: ${totalSize} bytes`)
    })
  })
})

Callback hell — error handling

Error handling in asynchronous code is tricky.

Does the following code catch the error that is thrown inside the setTimeout() callback?

try {
  setTimeout(() => {
    throw new Error('Error from timeout')
  }, 1000)
}
catch (e) {
  console.error(`Error was caught: ${e}`)
}

The answer is no. Remember that setTimeout() returns as soon as the callback has been set up, without waiting for the 1 second to elapse. Therefore, the try block ends before the error is thrown.

So how can we "catch" an error that occurs during an asynchronous action? As you can see in the examples in the previous sections, all of Node.js's asynchronous functions pass an err as the first parameter to the callback. If an error occurred, err is set to the Error object that was "thrown". Otherwise, err is set to null.

For example, fs.stat() gives an error if the file we try to read doesn't exist. We can use this to create a file if it doesn't exist:

// The arguments are the file to create and its initial contents.
// Run the program using node create-file.js FILENAME CONTENTS

fs.stat(process.argv[2], err => {
  if (err) {
    // File didn't exist, so create it
    fs.writeFile(process.argv[2], process.argv[3], err => {
      if (err) throw err

      console.log(`${process.argv[2]} created`)
    })
  }
  else console.log(`${process.argv[2]} already exists`)
})

If we run this program using node create-file.js new-file.txt abc, it will make the file new-file.txt with contents abc. If we run it again with node create-file.js new-file.txt def, it won't change the file because it already exists.

But most of the time, we don't want any special error handling. If an error happens in a sequence of asynchronous actions, we usually want to stop and report an error from the whole sequence. As you can see in the examples above, writing if (err) throw err for every easynchronous action gets quite tedious.

What is a Promise?

In JavaScript, a Promise represents an asynchronous task that "promises" to compute a value eventually. We write Promise<T> to denote a Promise that will compute a value of type T. This may sound very abstract, so here are some concrete examples:

  • Reading a file's contents can be represented as a Promise<string>
  • Looking up a user in a database can be represented as a Promise<User>
  • An asynchronous task that doesn't compute a value can be represented as a Promise<undefined>

It is important to keep in mind that a Promise<T> is different from a T. There is no way to "get" the T from a Promise<T>.

When a Promise computes a value, this is called "resolving" to the value. All Promises can also "reject" with an error.

Promise methods

.then()

This method is used to run Promises in sequence. Because running asynchronous tasks in sequence is extremely common, .then() is the primary way to compose Promises. Here is how it is used:

/*
  Note the `.promises`.
  `require('fs').promises` has the same functions as `require('fs')`,
  but they return Promises instead of taking callbacks.
*/
const fs = require('fs').promises

// Read 'a.txt'. `readPromise` is a `Promise<string>`.
const readPromise = fs.readFile('a.txt', 'utf8')
/*
  After `readPromise` finishes, write the result to 'b.txt'.
  `contents` will be the `string` that was read.
  We return a `Promise<undefined>` that represents the write.
*/
const writePromise = readPromise
  .then(contents => fs.writeFile('b.txt', contents))
// Once the write finishes, print a message
writePromise.then(_ => {
  console.log('Copying done')
})

In general, if we have a Promise<A> and a function A => Promise<B>, we can use .then() to chain them together into a single Promise<B>.

You can call .then() multiples times on the same Promise if multiple actions need to happen after the Promise finishes. For example, to copy a.txt to both b.txt and c.txt:

const aPromise = fs.readFile('a.txt', 'utf8')
aPromise.then(contents => fs.writeFile('b.txt', contents))
aPromise.then(contents => fs.writeFile('c.txt', contents))

Promise.resolve()

This function is used to make a Promise that immediately resolves to a given value. This is useful for a function which sometimes performs an asynchronous action but sometimes returns a pre-computed value. For example, we can save the contents of a file after reading it the first time:

let cachedContents
function getContents() {
  // If we have already read the file, just return its contents
  if (cachedContents !== undefined) {
    return Promise.resolve(cachedContents)
  }

  // Otherwise, read the file and store its contents
  const readPromise = fs.readFile('file.txt', 'utf8')
  readPromise.then(contents => {
    cachedContents = contents
  })
  return readPromise
}

.catch()

Error handling with Promises is much easier than with callbacks since .then() automatically handles errors that occur.

In this example, we concatenate the contents of two files. If a.txt doesn't exist, then fs.readFile() will reject and the aContents => ... function will never be called. If a.txt doesn't end with a newline, we throw an error, which will also cause the Promise to reject. The err => ... function will be called if the Promise rejects for any reason. It reurns a new Promise to run instead. Note that the concatenated => ... function will always be called because we catch any error that could have occurred.

fs.readFile('a.txt', 'utf8')
.then(aContents => {
  // If the contents of a.txt don't end with a newline character,
  // reject with an error
  if (!aContents.endsWith('\n')) {
    throw new Error('Missing newline at end of a.txt')
  }

  // Otherwise, read b.txt and concatenate the files' contents
  return fs.readFile('b.txt', 'utf8')
    .then(bContents => Promise.resolve(aContents + bContents))
})
// If either file didn't exist or a.txt didn't end with a newline,
// resolve to the empty string instead
.catch(err => Promise.resolve(''))
// Finally, write the string to c.txt
.then(concatenated => fs.writeFile('c.txt', concatenated))

Promise.all()

Promise.all() takes an array of Promises and returns a new Promise that resolves once all of the Promises have resolved. When it resolves, it computes an array containing the result of each of the Promises.

In this example, we find the most recent modification time of all files inside all subdirectories of a given directory. (For example, if a/b/c.txt was modified, that counts as a modification of a/b and a as well.)

// Returns a Promise<number> that computes the most recent
// modification time for any file inside a directory
const modifiedTime = path =>
  fs.stat(path).then(stats => {
    // mtimeMs is the modification time for this file/directory
    const {mtimeMs} = stats
    if (stats.isDirectory()) {
      // If this is a directory, compute the modification time
      // for all files/subdirectories inside it
      return fs.readdir(path)
        .then(files =>
          // Compute modification times of all contained files
          Promise.all(files.map(file =>
            modifiedTime(path + '/' + file)
          ))
        )
        // modifiedTimes will be an array containing the latest
        // modification time of each file in the directory
        .then(modifiedTimes =>
          Promise.resolve(Math.max(mtimeMs, ...modifiedTimes))
        )
    }
    // If this is a file, just return its modification time
    else return Promise.resolve(mtimeMs)
  })

Aside: monads

If you've ever used Haskell, you might have noticed that Promises look a lot like its IO monad. .then() is the equivalent of >>= ("bind"), which chains two IO operations together. And Promise.resolve() is the equivalent of return, which wraps a normal value in an instance of IO.

Just like monads in Haskell allow you to write code that looks imperative in a functional language, Promises let you write asynchronous programs that look like blocking programs, while still using callbacks under the hood. Towards the end of the course, we'll also cover JavaScript's async-await notation, which hides even the calls to .then() and Promise.resolve(). This is very similar to do notation in Haskell.