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.
Promise
?
What is a 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 Promise
s can also "reject" with an error.
Promise
methods
.then()
This method is used to run Promise
s in sequence.
Because running asynchronous tasks in sequence is extremely common, .then()
is the primary way to compose Promise
s.
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 Promise
s 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 Promise
s and returns a new Promise
that resolves once all of the Promise
s have resolved.
When it resolves, it computes an array containing the result of each of the Promise
s.
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 Promise
s 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, Promise
s 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.