promises.md 11 KB
Newer Older
Caleb C. Sander's avatar
Caleb C. Sander committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# 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](https://nodejs.org/api/fs.html).
There are quite a lot, so we'll look at four of the most useful ones:
- [`fs.readFile()`](https://nodejs.org/api/fs.html#fs_fs_readfile_path_options_callback), used to read the entire contents of a file
- [`fs.writeFile()`](https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback), used to set the entire contents of a file
- [`fs.readdir()`](https://nodejs.org/api/fs.html#fs_fs_readdir_path_options_callback), used to list the files in a directory
- [`fs.stat()`](https://nodejs.org/api/fs.html#fs_fs_stat_path_options_callback), 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](write-echo.js) is an example program that writes to a file and then reads its contents:
```js
// 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:
```js
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](size-sum.js) is a program that computes the total size of 3 files:
```js
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?
```js
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:
```js
// 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](create-file.js) 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 `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](copy-promises.js) is how it is used:
```js
/*
  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](copy-twice.js), to copy `a.txt` to both `b.txt` and `c.txt`:
```js
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](cache.js), we can save the contents of a file after reading it the first time:
```js
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](catch-file.js), 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.
```js
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](recursive-mtime.js), 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.)
```js
// 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
247
248
249
        .then(modifiedTimes =>
          Promise.resolve(Math.max(mtimeMs, ...modifiedTimes))
        )
Caleb C. Sander's avatar
Caleb C. Sander committed
250
251
252
253
254
255
256
257
258
259
260
261
262
    }
    // 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.
263
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()`.
Caleb C. Sander's avatar
Caleb C. Sander committed
264
This is very similar to `do` notation in Haskell.