Please support this book: buy it or donate

37. Promises for asynchronous programming



In this chapter, we explore Promises, yet another pattern for delivering asynchronous results.

This chapter builds on the previous chapter with background on asynchronous programming in JavaScript.

37.1. The basics of using Promises

Promises are a pattern for delivering asynchronous results.

37.1.1. Using a Promise-based function

The following code is an example of using the Promise-based function addAsync() (whose implementation is shown soon):

  1. addAsync(3, 4)
  2. .then(result => { // success
  3. assert.equal(result, 7);
  4. })
  5. .catch(error => { // failure
  6. assert.fail(error);
  7. });

Promises combine aspects of the callback pattern and the event pattern:

  • Like the callback pattern, Promises specialize in delivering one-off results.

  • Similar to some event patterns, a Promise-based function delivers its result by returning an object (a Promise). With that object, you register callbacks that handle results (.then()) and errors (.catch()).

  • One aspect of Promises that is unique, is that you can chain .then() and .catch(), because they both return Promises. That helps with sequentially invoking multiple asynchronous functions. We’ll explore the details later.

37.1.2. What is a Promise?

So what is a Promise? There are two ways of looking at it:

  • On one hand, it is a placeholder or container for the final result that will eventually be delivered.
  • On the other hand, it is an object with which you can register listeners.

37.1.3. Implementing a Promise-based function

This is how you implement a Promise-based function that adds two numbers x and y:

  1. function addAsync(x, y) {
  2. return new Promise(
  3. (resolve, reject) => { // (A)
  4. if (x === undefined || y === undefined) {
  5. reject(new Error('Must provide two parameters'));
  6. } else {
  7. resolve(x + y);
  8. }
  9. });
  10. }

With this style of returning a Promise, addAsync() immediately invokes the Promise constructor. The actual implementation of that function resides in the callback that is passed to that constructor (line A). That callback is provided with two functions:

  • resolve is used for delivering a result (in case of success).
  • reject is used for delivering an error (in case of failure).

37.1.4. States of promises

Figure 21: A Promise can be in either one of three states: pending, fulfilled, or rejected. If a Promise is in a final (non-pending) state, it is called settled.

Figure 21: A Promise can be in either one of three states: pending, fulfilled, or rejected. If a Promise is in a final (non-pending) state, it is called settled.

Fig. 21 depicts the three states a Promise can be in. Promises specialize in one-off results and protect you against race conditions (registering too early or too late):

  • If you register a .then() callback or a .catch() callback too early, it is notified once a Promise is settled.
  • Once a Promise is settled, the settlement value (result or error) is cached. Thus, if .then() or .catch() are called after the settlement, they receive the chaed value.
    Additionally, once a Promise is settled, its state and settlement value can’t change, anymore. That helps make code predictable and enforces the one-off nature of Promises.

Next, we’ll see more ways of creating Promises.

37.1.5. Promise.resolve(): create a Promise fulfilled with a given value

Promise.resolve(x) creates a Promise that is fulfilled with the value x:

  1. Promise.resolve(123)
  2. .then(x => {
  3. assert.equal(x, 123);
  4. });

If the parameter is already a Promise, it is returned unchanged:

  1. const abcPromise = Promise.resolve('abc');
  2. assert.equal(
  3. Promise.resolve(abcPromise),
  4. abcPromise);

Therefore, given an arbitrary value x, you can use Promise.resolve(x) to ensure you have a Promise.

Note that the name is resolve, not fulfill, because .resolve() returns a rejected Promise if its Parameter is a rejected Promise.

37.1.6. Promise.reject(): create a Promise rejected with a given value

Promise.reject(err) creates a Promise that is fulfilled with the value err:

  1. const myError = new Error('My error!');
  2. Promise.reject(myError)
  3. .catch(err => {
  4. assert.equal(err, myError);
  5. });

37.1.7. Returning and throwing in .then() callbacks

.then() handles Promise fulfillments. It returns a fresh Promise. How that Promise is settled depends on what happens inside the callback. Let’s look at three common cases.

37.1.7.1. Returning a non-Promise value

First, the callback can return a non-Promise value (line A). Consequently, the Promise returned by .then() is fulfilled with that value (as checked in line B):

  1. Promise.resolve('abc')
  2. .then(str => {
  3. return str + str; // (A)
  4. })
  5. .then(str2 => {
  6. assert.equal(str2, 'abcabc'); // (B)
  7. });
37.1.7.2. Returning a Promise

Second, the callback can return a Promise p (line A). Consequently, p “becomes” what .then() returns (the Promise that .then() has already returned is effectively replaced by p).

  1. Promise.resolve('abc')
  2. .then(str => {
  3. return Promise.resolve(123); // (A)
  4. })
  5. .then(num => {
  6. assert.equal(num, 123);
  7. });
37.1.7.3. Throwing an exception

Third, the callback can throw an exception. Consequently, the Promise returned by .then() is rejected with that exception. That is, a synchronous error is converted into an asynchronous error.

  1. const myError = new Error('My error!');
  2. Promise.resolve('abc')
  3. .then(str => {
  4. throw myError;
  5. })
  6. .catch(err => {
  7. assert.equal(err, myError);
  8. });

37.1.8. .catch() and its callback

The only difference between .then() and .catch() is that the latter is triggered by rejections, not fulfillments. However, both methods turn the actions of their callbacks into Promises in the same manner. For example, in the following code, the value returned by the .catch() callback in line A becomes a fulfillment value:

  1. const err = new Error();
  2. Promise.reject(err)
  3. .catch(e => {
  4. assert.equal(e, err);
  5. // Something went wrong, use a default value
  6. return 'default value'; // (A)
  7. })
  8. .then(str => {
  9. assert.equal(str, 'default value');
  10. });

37.1.9. Chaining method calls

Due to .then() and .catch() always returning Promises, you can create arbitrary long chains of method calls:

  1. function myAsyncFunc() {
  2. return asyncFunc1()
  3. .then(result1 => {
  4. // ···
  5. return asyncFunc2(); // a Promise
  6. })
  7. .then(result2 => {
  8. // ···
  9. return result2 || '(Empty)'; // not a Promise
  10. })
  11. .then(result3 => {
  12. // ···
  13. return asyncFunc4(); // a Promise
  14. });
  15. }

In a way, .then() is the asynchronous version of the synchronous semicolon:

  • .then() executes two asynchronous operations sequentially.
  • The semicolon executes two synchronous operations sequentially.
    You can also add .catch() into the mix and let it handle multiple error sources at the same time:
  1. asyncFunc1()
  2. .then(result1 => {
  3. // ···
  4. return asyncFunction2();
  5. })
  6. .then(result2 => {
  7. // ···
  8. })
  9. .catch(error => {
  10. // Failure: handle errors of asyncFunc1(), asyncFunc2()
  11. // and any (sync) exceptions thrown in previous callbacks
  12. });

37.1.10. Advantages of promises

These are some of the advantages of Promises over plain callbacks when it comes to handling one-off results:

  • The type signatures of Promise-based functions and methods are cleaner: If a function is callback-based, some parameters are about input, while the one or two callbacks at the end are about output. With Promises, everything output-related is handled via the returned value.

  • Chaining asynchronous processing steps is more convenient.

  • Error handling takes care of both synchronous and asynchronous errors.

  • Composing Promise-based functions is slightly easier, because you can use some of the synchronous tools (e.g. .map()) that call functions and process results. We’ll see an example at the end of this chapter.

  • Promises are a single standard that is slowly replacing several, mutually incompatible alternatives. For example, in Node.js, many functions are now available in Promise-based versions. And new asynchronous browser APIs are usually Promise-based.

One of the biggest advantage of Promises involves not working with them directly: They are the foundation of async functions, a synchronous-looking syntax for performing asynchronous computations. Asynchronous functions are covered in the next chapter.

37.2. Examples

Seeing them in action helps with understanding Promises. Let’s look at examples.

37.2.1. Node.js: Reading a file asynchronously

Consider the following text file person.json with JSON data in it:

  1. {
  2. "first": "Jane",
  3. "last": "Doe"
  4. }

Let’s look at two versions of code that reads this file and parses it into an object. First, a callback-based version. Second, a Promise-based version.

37.2.1.1. The callback-based version

The following code reads the contents of this file and converts it to a JavaScript object. It is based on Node.js-style callbacks:

  1. import * as fs from 'fs';
  2. fs.readFile('person.json',
  3. (error, text) => {
  4. if (error) { // (A)
  5. // Failure
  6. assert.fail(error);
  7. } else {
  8. // Success
  9. try { // (B)
  10. const obj = JSON.parse(text); // (C)
  11. assert.deepEqual(obj, {
  12. first: 'Jane',
  13. last: 'Doe',
  14. });
  15. } catch (e) {
  16. // Invalid JSON
  17. assert.fail(e);
  18. }
  19. }
  20. });

fs is a built-in Node.js module for file system operations. We use the callback-based function fs.readFile() to read a file whose name is person.json. If we succeed, the content is delivered via the parameter text, as a string. In line C, we convert that string from the text-based data format JSON into a JavaScript object. JSON is part of JavaScript’s standard library.

Note that there are two error handling mechanisms: The if in line A takes care of asynchronous errors reported by fs.readFile(), while the try in line B takes care of synchronous errors reported by JSON.parse().

37.2.1.2. The Promise-based version

The following code uses readFileAsync(), a Promise-based version of fs.readFile() (created via util.promisify(), which is explained later):

  1. readFileAsync('person.json')
  2. .then(text => { // (A)
  3. // Success
  4. const obj = JSON.parse(text);
  5. assert.deepEqual(obj, {
  6. first: 'Jane',
  7. last: 'Doe',
  8. });
  9. })
  10. .catch(err => { // (B)
  11. // Failure: file I/O error or JSON syntax error
  12. assert.fail(err);
  13. });

Function readFileAsync() returns a Promise. In line A, we specify a success callback via method .then() of that Promise. The remaining code in then’s callback is synchronous.

.then() returns a Promise, which enables the invocation of the Promise method .catch() in line B. We use it to specify a failure callback.

Note that .catch() lets us handle both the asynchronous errors of readFileAsync() and the synchronous errors of JSON.parse(). We’ll see later how exactly that works.

37.2.2. Browsers: Promisifying XMLHttpRequest

We have previously seen the event-based XMLHttpRequest API for downloading data in web browsers. The following function promisifies that API:

  1. function httpGet(url) {
  2. return new Promise(
  3. (resolve, reject) => {
  4. const xhr = new XMLHttpRequest();
  5. xhr.onload = () => {
  6. if (xhr.status === 200) {
  7. resolve(xhr.responseText); // (A)
  8. } else {
  9. // Something went wrong (404 etc.)
  10. reject(new Error(xhr.statusText)); // (B)
  11. }
  12. }
  13. xhr.onerror = () => {
  14. reject(new Error('Network error')); // (C)
  15. };
  16. xhr.open('GET', url);
  17. xhr.send();
  18. });
  19. }

Note how the results of XMLHttpRequest are handled via resolve() and reject():

  • A successful outcome leads to the returned Promise being fullfilled with it (line A).
  • Errors lead to the Promise being rejected (lines B and C).
    This is how you use httpGet():
  1. httpGet('http://example.com/textfile.txt')
  2. .then(content => {
  3. assert.equal(content, 'Content of textfile.txt\n');
  4. })
  5. .catch(error => {
  6. assert.fail(error);
  7. });

37.2.3. Node.js: util.promisify()

util.promisify() is a utility function that converts a callback-based function f into a Promise-based one. That is, we are going from this type signature:

  1. f(arg_1, ···, arg_n, (err: Error, result: T) => void) : void

To this type signature:

  1. f(arg_1, ···, arg_n) : Promise<T>

The following code promisifies the callback-based fs.readFile() (line A) and uses it:

  1. import * as fs from 'fs';
  2. import {promisify} from 'util';
  3. const readFileAsync = promisify(fs.readFile); // (A)
  4. readFileAsync('some-file.txt', {encoding: 'utf8'})
  5. .then(text => {
  6. assert.equal(text, 'The content of some-file.txt\n');
  7. })
  8. .catch(err => {
  9. assert.fail(err);
  10. });

37.2.4. Browsers: Fetch API

All modern browsers support Fetch, a new Promise-based API for downloading data. Think of it as a Promise-based version of XMLHttpRequest. The following is an excerpt of the API:

  1. interface Body {
  2. text() : Promise<string>;
  3. ···
  4. }
  5. interface Response extends Body {
  6. ···
  7. }
  8. declare function fetch(str) : Promise<Response>;

That means, you can use fetch() as follows:

  1. fetch('http://example.com/textfile.txt')
  2. .then(response => response.text())
  3. .then(text => {
  4. assert.equal(text, 'Content of textfile.txt\n');
  5. });

37.3. Error handling: don’t mix rejections and exceptions

The general rule for error handling in asynchronous code is:

Don’t mix (asynchronous) rejections and (synchronous) exceptions

The rationale is that your code is less redundant if you can use a single error handling mechanism.

Alas, it is easy to accidentally break that rule. For example:

  1. // Don’t do this
  2. function asyncFunc() {
  3. doSomethingSync(); // (A)
  4. return doSomethingAsync()
  5. .then(result => {
  6. // ···
  7. });
  8. }

The problem is that, if an exception is thrown in line A, then asyncFunc() will throw an exception. Callers of that function only expect rejections and are not prepared for an exception. There are three ways in which we can fix this issue.

We can wrap the whole body of the function in a try-catch statement and return a rejected Promise if an exception is thrown:

  1. // Solution 1
  2. function asyncFunc() {
  3. try {
  4. doSomethingSync();
  5. return doSomethingAsync()
  6. .then(result => {
  7. // ···
  8. });
  9. } catch (err) {
  10. return Promise.reject(err);
  11. }
  12. }

Given that .then() converts exceptions to rejections, we can execute doSomethingSync() inside a .then() callback. To do so, we start a Promise chain via Promise.resolve(). We ignore the fulfillment value, undefined, of that initial Promise.

  1. // Solution 2
  2. function asyncFunc() {
  3. return Promise.resolve()
  4. .then(() => {
  5. doSomethingSync();
  6. return doSomethingAsync();
  7. })
  8. .then(result => {
  9. // ···
  10. });
  11. }

Lastly, new Promise() also converts exceptions to rejections. Using this constructor is therefore similar to the previous solution:

  1. // Solution 3
  2. function asyncFunc() {
  3. return new Promise((resolve, reject) => {
  4. doSomethingSync();
  5. resolve(doSomethingAsync());
  6. })
  7. .then(result => {
  8. // ···
  9. });
  10. }

37.4. Promise-based functions start synchronously, settle asynchronously

Most Promise-based functions are executed as follows:

  • Their execution starts right away, synchronously.
  • But the Promise they return is guaranteed to be settled asynchronously (if ever).
    The following code demonstrates that:
  1. function asyncFunc() {
  2. console.log('asyncFunc');
  3. return new Promise(
  4. (resolve, _reject) => {
  5. console.log('Callback of new Promise()');
  6. resolve();
  7. });
  8. }
  9. console.log('Start');
  10. asyncFunc()
  11. .then(() => {
  12. console.log('Callback of .then()'); // (A)
  13. });
  14. console.log('End');
  15. // Output:
  16. // 'Start'
  17. // 'asyncFunc'
  18. // 'Callback of new Promise()'
  19. // 'End'
  20. // 'Callback of .then()'

We can see that the callback of new Promise() is executed before the end of the code, while the result is delivered later (line A).

That means that your code can rely on run-to-completion semantics (as explained in the previous chapter) and that chaining Promises won’t starve other tasks of processing time.

Additionally, this rule leads to Promise-based functions consistently returning results asynchronously. Not sometimes immediately, sometimes asynchronously. This kind of predictability makes code easier to work with. For more information, consult “Designing APIs for Asynchrony” by Isaac Z. Schlueter.

37.5. Promise.all(): concurrency and Arrays of Promises

37.5.1. Sequential execution vs. concurrent execution

Consider the following code:

  1. const asyncFunc1 = () => Promise.resolve('one');
  2. const asyncFunc2 = () => Promise.resolve('two');
  3. asyncFunc1()
  4. .then(result1 => {
  5. assert.equal(result1, 'one');
  6. return asyncFunc2();
  7. })
  8. .then(result2 => {
  9. assert.equal(result2, 'two');
  10. });

With .then(), Promise-based functions are always executed sequentially: Only after the result of asyncFunc1() is settled, will asyncFunc2() be executed.

In contrast, the helper function Promise.all() executes Promise-based functions in a manner that is more concurrent:

  1. Promise.all([asyncFunc1(), asyncFunc2()])
  2. .then(arr => {
  3. assert.deepEqual(arr, ['one', 'two']);
  4. });

Its type signature is:

  1. Promise.all<T>(promises: Iterable<Promise<T>>): Promise<T[]>

The parameter promises is an iterable of Promises. The result is a single Promise that is settled as follows:

  • If and when all input Promises are fulfilled, the output Promise is fulfilled with an Array of the fulfillment values.
  • If at least one input Promise is rejected, the output Promise is rejected with the rejection value of the input Promise.
    In other words: You go from an iterable of Promises to a Promise for an Array.

37.5.2. Concurrency tip: Focus on when computations start

Tip for determining how “concurrent” asynchronous code is: Focus on when asynchronous computations start, not on how Promises are handled. For example, the following code that uses .then(), is as “concurrent” as the version that uses Promise.all():

  1. const promise1 = asyncFunc1();
  2. const promise2 = asyncFunc2();
  3. promise1
  4. .then(result1 => {
  5. assert.equal(result1, 'one');
  6. return promise2;
  7. })
  8. .then(result2 => {
  9. assert.equal(result2, 'two');
  10. });

Both asyncFunc1() and asyncFunc2() start at roughly the same time. Once both Promises are fulfilled, both .then() calls are executed almost immediately. If promise1 is fulfilled first, this approach is even faster than using Promise.all() (which waits until all Promises are fulfilled).

37.5.3. Promise.all() is fork-join

Promise.all() is loosely related to the concurrency pattern “fork join”. For example:

  1. Promise.all([
  2. // Fork async computations
  3. httpGet('http://example.com/file1.txt'),
  4. httpGet('http://example.com/file2.txt'),
  5. ])
  6. // Join async computations
  7. .then(([text1, text2]) => {
  8. assert.equal(text1, 'Content of file1.txt\n');
  9. assert.equal(text2, 'Content of file2.txt\n');
  10. });

37.5.4. Asynchronous .map() via Promise.all()

Array transformation methods such as .map(), .filter(), etc., are made for synchronous computations. For example:

  1. function timesTwoSync(x) {
  2. return 2 * x;
  3. }
  4. const arr = [1, 2, 3];
  5. const result = arr.map(timesTwoSync);
  6. assert.deepEqual(result, [2, 4, 6]);

Is it possible for the callback of .map() to be a Promise-based function? Yes it is, if you use Promise.all() to convert an Array of Promises to an Array of (fulfillment) values:

  1. function timesTwoAsync(x) {
  2. return new Promise(resolve => resolve(x * 2));
  3. }
  4. const arr = [1, 2, 3];
  5. const promiseArr = arr.map(timesTwoAsync);
  6. Promise.all(promiseArr)
  7. .then(result => {
  8. assert.deepEqual(result, [2, 4, 6]);
  9. });
37.5.4.1. A more realistic example

This following code is a more realistic example: We are using .map() to convert the example from the section on fork-join, into a function whose parameter is an Array with URLs of text files to download.

  1. function downloadTexts(fileUrls) {
  2. const promisedTexts = fileUrls.map(httpGet);
  3. return Promise.all(promisedTexts);
  4. }
  5. downloadTexts([
  6. 'http://example.com/file1.txt',
  7. 'http://example.com/file2.txt',
  8. ])
  9. .then(texts => {
  10. assert.deepEqual(
  11. texts, [
  12. 'Content of file1.txt\n',
  13. 'Content of file2.txt\n',
  14. ]);
  15. });

37.6. Tips for chaining Promises

This section gives tips for chaining Promises.

37.6.1. Chaining mistake: losing the tail

Problem:

  1. // Don’t do this
  2. function foo() {
  3. const promise = asyncFunc();
  4. promise.then(result => {
  5. // ···
  6. });
  7. return promise;
  8. }

Computation starts with the Promise returned by asyncFunc(). But afterwards, computation continues and another Promise is created, via .then(). foo() returns the former Promise, but should return the latter. This is how to fix it:

  1. function foo() {
  2. const promise = asyncFunc();
  3. return promise.then(result => {
  4. // ···
  5. });
  6. }

37.6.2. Chaining mistake: nesting

Problem:

  1. // Don’t do this
  2. asyncFunc1()
  3. .then(result1 => {
  4. return asyncFunc2()
  5. .then(result2 => { // (A)
  6. // ···
  7. });
  8. });

The .then() in line A is nested. A flat structure would be better:

  1. asyncFunc1()
  2. .then(result1 => {
  3. return asyncFunc2();
  4. })
  5. .then(result2 => {
  6. // ···
  7. });

37.6.3. Chaining mistake: more nesting than necessary

This is another example of avoidable nesting:

  1. // Don’t do this
  2. asyncFunc1()
  3. .then(result1 => {
  4. if (result1 < 0) {
  5. return asyncFuncA()
  6. .then(resultA => 'Result: ' + resultA);
  7. } else {
  8. return asyncFuncB()
  9. .then(resultB => 'Result: ' + resultB);
  10. }
  11. });

We can once again get a flat structure:

  1. asyncFunc1()
  2. .then(result1 => {
  3. return result1 < 0 ? asyncFuncA() : asyncFuncB();
  4. })
  5. .then(resultAB => {
  6. return 'Result: ' + resultAB;
  7. });

37.6.4. Nesting per se is not evil

In the following code, we benefit from not nesting:

  1. db.open()
  2. .then(connection => { // (A)
  3. return connection.select({ name: 'Jane' })
  4. .then(result => { // (B)
  5. // Process result
  6. // Use `connection` to make more queries
  7. })
  8. // ···
  9. .catch(error => {
  10. // handle errors
  11. })
  12. .finally(() => {
  13. connection.close(); // (C)
  14. });
  15. })

We are receiving an asynchronous result in line A. In line B, we are nesting, so that we have access to variable connection inside the callback and in line C.

37.6.5. Chaining mistake: creating Promises instead of chaining

Problem:

  1. // Don’t do this
  2. class Model {
  3. insertInto(db) {
  4. return new Promise((resolve, reject) => { // (A)
  5. db.insert(this.fields)
  6. .then(resultCode => {
  7. this.notifyObservers({event: 'created', model: this});
  8. resolve(resultCode);
  9. }).catch(err => {
  10. reject(err);
  11. })
  12. });
  13. }
  14. // ···
  15. }

In line A, we are creating a Promise to deliver the result of db.insert(). That is unnecessarily verbose and can be simplified:

  1. class Model {
  2. insertInto(db) {
  3. return db.insert(this.fields)
  4. .then(resultCode => {
  5. this.notifyObservers({event: 'created', model: this});
  6. return resultCode;
  7. });
  8. }
  9. // ···
  10. }

The key idea is that we don’t need to create a Promise; we can return the result of the .then() call. An additional benefit is that we don’t need to catch and re-reject the failure of db.insert(). We simply pass its rejection on, to the caller of .insertInto().

37.7. Further reading