One of the most powerful aspects of JavaScript is how easily it handles asynchronous programming. As a language created for the web, JavaScript needed to be able to respond to asynchronous user interactions, such as clicks and key presses, from the beginning. Node.js made asynchronous programming in JavaScript more popular by using callbacks as an alternative to events. But as more programs started using asynchronous programming, events and callbacks weren’t powerful enough to support everything developers wanted to do. Promises are the solution to this problem.
Promises are another option for asynchronous programming, and they work like futures and deferreds do in other languages. Like events and callbacks, a promise specifies some code to be executed later, but promises also explicitly indicate whether the code succeeded or failed. You can chain promises together based on success or failure in ways that make your code easier to understand and debug.
This chapter shows you how promises work. However, for a complete understanding, it’s important to understand some of the basic concepts upon which promises are built.
JavaScript engines are built on the concept of a single-threaded event loop. Single-threaded means that only one piece of code is executed at a time. Contrast this with languages like Java or C++, where threads can allow multiple different pieces of code to execute at the same time. Maintaining and protecting state when multiple pieces of code can access and change that state is a difficult problem and a frequent source of bugs in thread-based software.
JavaScript engines can execute only one piece of code at a time, so they need to keep track of code that is meant to run. That code is kept in a job queue. Whenever a piece of code is ready to be executed, it is added to the job queue. When the JavaScript engine is finished executing code, the event loop executes the next job in the queue. The event loop is a process inside the JavaScript engine that monitors code execution and manages the job queue. Keep in mind that as a queue, job execution runs from the first job in the queue to the last.
When a user clicks a button or presses a key on the keyboard, an event like onclick is triggered. That event might respond to the interaction by adding a new job to the back of the job queue. This is JavaScript’s most basic form of asynchronous programming. The event handler code doesn’t execute until the event fires, and when it does execute, it has the appropriate context. For example:
let button = document.getElementById("my-btn");
button.onclick = function(event) {
console.log("Clicked");
};
In this code, console.log("Clicked") will not be executed until button is clicked. When button is clicked, the function assigned to onclick is added to the back of the job queue and will be executed when all other jobs ahead of it are complete.
Events work well for simple interactions, but chaining multiple separate asynchronous calls together is more complicated because you must keep track of the event target (button in this example) for each event. Additionally, you need to ensure that all appropriate event handlers are added before the first time an event occurs. For instance, if button is clicked before onclick is assigned, nothing will happen. So although events are useful for responding to user interactions and similar infrequent functionality, they aren’t very flexible for more complex needs.
Node.js advanced the asynchronous programming model by popularizing callbacks. The callback pattern is similar to the event model because the asynchronous code doesn’t execute until a later point in time. It’s different because the function to call is passed in as an argument, as shown here:
readFile("example.txt", function(err, contents) {
if (err) {
throw err;
}
console.log(contents);
});
console.log("Hi!");
This example uses the traditional Node.js error-first callback style. The readFile() function is intended to read from a file on disk (specified as the first argument) and then execute the callback (the second argument) when complete. If there’s an error, the err argument of the callback is an error object; otherwise, the contents argument contains the file contents as a string.
Using the callback pattern, readFile() begins executing immediately and pauses when it starts reading from the disk. That means console.log("Hi!") is output immediately after readFile() is called, before console.log(contents) prints anything. When readFile() finishes, it adds a new job to the end of the job queue with the callback function and its arguments. That job executes upon completion of all other jobs ahead of it.
The callback pattern is more flexible than events because chaining multiple calls together is easier with callbacks. Here’s an example:
readFile("example.txt", function(err, contents) {
if (err) {
throw err;
}
writeFile("example.txt", function(err) {
if (err) {
throw err;
}
console.log("File was written!");
});
});
In this code, a successful call to readFile() results in another asynchronous call, this time to the writeFile() function. Note that the same basic pattern of checking err is present in both functions. When readFile() is complete, it adds a job to the job queue that calls the writeFile() function if there are no errors. Then, writeFile() adds a job to the job queue when it finishes.
This pattern works fairly well, but you can quickly find yourself in callback hell. Callback hell occurs when you nest too many callbacks, like this:
method1(function(err, result) {
if (err) {
throw err;
}
method2(function(err, result) {
if (err) {
throw err;
}
method3(function(err, result) {
if (err) {
throw err;
}
method4(function(err, result) {
if (err) {
throw err;
}
method5(result);
});
});
});
});
Nesting multiple method calls, as this example does, creates a tangled web of code that is difficult to understand and debug. Callbacks also present problems when you want to implement more complex functionality. What if you want two asynchronous operations to run in parallel and notify you when they’re both complete? What if you want to start two asynchronous operations at the same time but only take the result of the first one to complete? In these cases, you’d need to track multiple callbacks and cleanup operations, and promises greatly improve such situations.
A promise is a placeholder for the result of an asynchronous operation. Instead of subscribing to an event or passing a callback to a function, the function can return a promise, as shown here:
// readFile promises to complete at some point in the future
let promise = readFile("example.txt");
In this code, readFile() doesn’t start reading the file immediately: that will happen later. Instead, the function returns a promise object representing the asynchronous read operation so you can work with it in the future. Exactly when you’ll be able to work with that result depends entirely on how the promise’s life cycle concludes.
Each promise goes through a short life cycle starting in the pending state, which indicates that the asynchronous operation hasn’t completed yet. A pending promise is considered unsettled. The promise in the previous example is in the pending state as soon as the readFile() function returns it. Once the asynchronous operation completes, the promise is considered settled and enters one of two possible states:
Fulfilled The promise’s asynchronous operation has completed successfully.
Rejected The promise’s asynchronous operation didn’t complete successfully due to either an error or some other cause.
An internal [[PromiseState]] property is set to "pending", "fulfilled", or "rejected" to reflect the promise’s state. This property isn’t exposed on promise objects, so you can’t determine which state the promise is in programmatically. But you can take a specific action when a promise changes state by using the then() method.
The then() method is present on all promises and takes two arguments. The first argument is a function to call when the promise is fulfilled. Any additional data related to the asynchronous operation is passed to this fulfillment function. The second argument is a function to call when the promise is rejected. Similar to the fulfillment function, the rejection function is passed any additional data related to the rejection.
NOTE
Any object that implements the then() method as described in the preceding paragraph is called a thenable. All promises are thenables, but all thenables are not promises.
Both arguments to then() are optional, so you can listen for any combination of fulfillment and rejection. For example, consider this set of then() calls:
let promise = readFile("example.txt");
promise.then(function(contents) {
// fulfillment
console.log(contents);
}, function(err) {
// rejection
console.error(err.message);
});
promise.then(function(contents) {
// fulfillment
console.log(contents);
});
promise.then(null, function(err) {
// rejection
console.error(err.message);
});
All three then() calls operate on the same promise. The first call listens for fulfillment and rejection. The second only listens for fulfillment; errors won’t be reported. The third just listens for rejection and doesn’t report success.
Promises also have a catch() method that behaves the same as then() when only a rejection handler is passed. For example, the following catch() and then() calls are functionally equivalent:
promise.catch(function(err) {
// rejection
console.error(err.message);
});
// is the same as:
promise.then(null, function(err) {
// rejection
console.error(err.message);
});
The then() and catch() methods are intended to be used in combination to properly handle the result of asynchronous operations. This system is better than using events and callbacks because it clearly indicates whether the operation succeeded or failed completely. (Events tend not to fire when there’s an error, and in callbacks you must always remember to check the error argument.) Just know that if you don’t attach a rejection handler to a promise, all failures will happen silently. Always attach a rejection handler, even if the handler just logs the failure.
A fulfillment or rejection handler will still be executed even if it is added to the job queue after the promise is already settled. This allows you to add new fulfillment and rejection handlers at any time and guarantee that they will be called. For example:
let promise = readFile("example.txt");
// original fulfillment handler
promise.then(function(contents) {
console.log(contents);
// now add another
promise.then(function(contents) {
console.log(contents);
});
});
In this code, the fulfillment handler adds another fulfillment handler to the same promise. The promise is already fulfilled at this point, so the new fulfillment handler is added to the job queue and called when all other preceding jobs on the queue are complete. Rejection handlers work the same way.
NOTE
Each call to then() or catch() creates a new job to be executed when the promise is resolved. But these jobs end up in a separate job queue that is reserved strictly for promises. The precise details of this second job queue aren’t important for understanding how to use promises as long as you understand how job queues work in general.
New promises are created using the Promise constructor. This constructor accepts a single argument: a function called the executor, which contains the code to initialize the promise. The executor is passed two functions named resolve() and reject() as arguments. The resolve() function is called when the executor has finished successfully to signal that the promise is ready to be resolved, whereas the reject() function indicates that the executor has failed.
Here’s an example that uses a promise in Node.js to implement the readFile() function you saw earlier in this chapter:
// Node.js example
let fs = require("fs");
function readFile(filename) {
return new Promise(function(resolve, reject) {
// trigger the asynchronous operation
fs.readFile(filename, { encoding: "utf8" }, function(err, contents) {
// check for errors
if (err) {
reject(err);
return;
}
// the read succeeded
resolve(contents);
});
});
}
let promise = readFile("example.txt");
// listen for both fulfillment and rejection
promise.then(function(contents) {
// fulfillment
console.log(contents);
}, function(err) {
// rejection
console.error(err.message);
});
In this example, the native Node.js fs.readFile() asynchronous call is wrapped in a promise. The executor either passes the error object to the reject() function or passes the file contents to the resolve() function.
Keep in mind that the executor runs immediately when readFile() is called. When either resolve() or reject() is called inside the executor, a job is added to the job queue to resolve the promise. This is called job scheduling, and if you’ve ever used the setTimeout() or setInterval() functions, you’re already familiar with it. In job scheduling, you add a new job to the job queue to say, “Don’t execute this right now, but execute it later.” For instance, the setTimeout() function lets you specify a delay before a job is added to the queue:
// add this function to the job queue after 500 ms have passed
setTimeout(function() {
console.log("Timeout");
}, 500)
console.log("Hi!");
This code schedules a job to be added to the job queue after 500 ms. The two console.log() calls produce the following output:
Hi!
Timeout
Thanks to the 500 ms delay, the output that the function passed to setTimeout() was shown after the output from the console.log("Hi!") call.
Promises work similarly. The promise executor executes immediately, before anything that appears after it in the source code. For instance:
let promise = new Promise(function(resolve, reject) {
console.log("Promise");
resolve();
});
console.log("Hi!");
The output for this code is:
Promise
Hi!
Calling resolve() triggers an asynchronous operation. Functions passed to then() and catch() are executed asynchronously, because these are also added to the job queue. Here’s an example:
let promise = new Promise(function(resolve, reject) {
console.log("Promise");
resolve();
});
promise.then(function() {
console.log("Resolved.");
});
console.log("Hi!");
The output for this example is:
Promise
Hi!
Resolved
Note that even though the call to then() appears before the line console.log("Hi!"), it doesn’t actually execute until later (unlike the executor). The reason is that fulfillment and rejection handlers are always added to the end of the job queue after the executor has completed.
The Promise constructor is the best way to create unsettled promises due to the dynamic nature of what the promise executor does. But if you want a promise to represent just a single known value, it doesn’t make sense to schedule a job that simply passes a value to the resolve() function. Instead, you can use either of two methods that create settled promises given a specific value.
The Promise.resolve() method accepts a single argument and returns a promise in the fulfilled state. That means no job scheduling occurs, and you need to add one or more fulfillment handlers to the promise to retrieve the value. For example:
let promise = Promise.resolve(42);
promise.then(function(value) {
console.log(value); // 42
});
This code creates a fulfilled promise so the fulfillment handler receives 42 as value. If a rejection handler were added to this promise, the rejection handler would never be called because the promise will never be in the rejected state.
You can also create rejected promises by using the Promise.reject() method. This works like Promise.resolve() except the created promise is in the rejected state, as follows:
let promise = Promise.reject(42);
promise.catch(function(value) {
console.log(value); // 42
});
Any additional rejection handlers added to this promise would be called but not fulfillment handlers.
NOTE
If you pass a promise to either the Promise.resolve() or Promise.reject() method, the promise is returned without modification.
Both Promise.resolve() and Promise.reject() also accept non-promise thenables as arguments. When passed a non-promise thenable, these methods create a new promise that is called after the then() function.
A non-promise thenable is created when an object has a then() method that accepts a resolve and a reject argument, like this:
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
The thenable object in this example has no characteristics associated with a promise other than the then() method. You can call Promise.resolve() to convert thenable into a fulfilled promise:
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
let p1 = Promise.resolve(thenable);
p1.then(function(value) {
console.log(value); // 42
});
In this example, Promise.resolve() calls thenable.then() so a promise state can be determined. The promise state for thenable is fulfilled because resolve(42) is called inside the then() method. A new promise called p1 is created in the fulfilled state with the value passed from thenable (that is, 42), and the fulfillment handler for p1 receives 42 as the value.
You can use the same process with Promise.resolve() to create a rejected promise from a thenable:
let thenable = {
then: function(resolve, reject) {
reject(42);
}
};
let p1 = Promise.resolve(thenable);
p1.catch(function(value) {
console.log(value); // 42
});
This example is similar to the previous example, except thenable is rejected. When thenable.then() executes, a new promise is created in the rejected state with a value of 42. That value is then passed to the rejection handler for p1.
Promise.resolve() and Promise.reject() work like this to allow you to easily work with non-promise thenables. Many libraries used thenables prior to promises being introduced in ECMAScript 6, so the ability to convert thenables into formal promises is important for backward compatibility with previously existing libraries. When you’re unsure whether an object is a promise, passing the object through Promise.resolve() or Promise.reject() (depending on your anticipated result) is the best way to find out because promises just pass through unchanged.
If an error is thrown inside an executor, the promise’s rejection handler is called. For example:
let promise = new Promise(function(resolve, reject) {
throw new Error("Explosion!");
});
promise.catch(function(error) {
console.log(error.message); // "Explosion!"
});
In this code, the executor intentionally throws an error. An implicit try-catch is inside every executor so that the error is caught and then passed to the rejection handler. The previous example is equivalent to this:
let promise = new Promise(function(resolve, reject) {
try {
throw new Error("Explosion!");
} catch (ex) {
reject(ex);
}
});
promise.catch(function(error) {
console.log(error.message); // "Explosion!"
});
The executor catches any thrown errors to simplify this common use case, but an error thrown in the executor is only reported when a rejection handler is present. Otherwise, the error is suppressed. This became a problem for developers early on when using promises, and JavaScript environments address it by providing hooks for catching rejected promises.
One of the most controversial aspects of promises is the silent failure that occurs when a promise is rejected without a rejection handler. Some consider this the biggest flaw in the specification because it’s the only part of the JavaScript language that doesn’t make errors apparent.
Determining whether a promise rejection was handled isn’t straightforward due to the nature of promises. For instance, consider this example:
let rejected = Promise.reject(42);
// at this point, rejected is unhandled
// some time later...
rejected.catch(function(value) {
// now rejected has been handled
console.log(value);
});
You can call then() or catch() at any point and have them work correctly regardless of whether the promise is settled or not, making it difficult to know precisely when a promise will be handled. In this case, the promise is rejected immediately but isn’t handled until later.
Although it’s possible that a future version of ECMAScript will address this problem, both Node.js and browsers have implemented changes to address this developer pain point. They aren’t part of the ECMAScript 6 specification but are valuable tools when you’re using promises.
Node.js emits two events on the process object that are related to promise rejection handling:
unhandledRejection Emitted when a promise is rejected and no rejection handler is called within one turn of the event loop
rejectionHandled Emitted when a promise is rejected and a rejection handler is called after one turn of the event loop
These events are designed to work together to help identify promises that are rejected and not handled.
The unhandledRejection event handler is passed the rejection reason (frequently an error object) and the promise that was rejected as arguments. The following code shows unhandledRejection in action:
let rejected;
process.on("unhandledRejection", function(reason, promise) {
console.log(reason.message); // "Explosion!"
console.log(rejected === promise); // true
});
rejected = Promise.reject(new Error("Explosion!"));
This example creates a rejected promise with an error object and listens for the unhandledRejection event. The event handler receives the error object as the first argument and the promise as the second.
The rejectionHandled event handler has only one argument, which is the promise that was rejected. For example:
let rejected;
process.on("rejectionHandled", function(promise) {
console.log(rejected === promise); // true
});
rejected = Promise.reject(new Error("Explosion!"));
// wait to add the rejection handler
setTimeout(function() {
rejected.catch(function(value) {
console.log(value.message); // "Explosion!"
});
}, 1000);
Here, the rejectionHandled event is emitted when the rejection handler is finally called. If the rejection handler were attached directly to rejected after rejected is created, the event wouldn’t be emitted. The rejection handler would instead be called during the same turn of the event loop where rejected was created, which isn’t useful.
To properly track potentially unhandled rejections, use the events rejectionHandled and unhandledRejection to store a list of potentially unhandled rejections. Then wait some period of time to inspect the list. For example, look at this simple unhandled rejection tracker:
let possiblyUnhandledRejections = new Map();
// when a rejection is unhandled, add it to the map
process.on("unhandledRejection", function(reason, promise) {
possiblyUnhandledRejections.set(promise, reason);
});
process.on("rejectionHandled", function(promise) {
possiblyUnhandledRejections.delete(promise);
});
setInterval(function() {
possiblyUnhandledRejections.forEach(function(reason, promise) {
console.log(reason.message ? reason.message : reason);
// do something to handle these rejections
handleRejection(promise, reason);
});
possiblyUnhandledRejections.clear();
}, 60000);
This code uses a map to store promises and their rejection reasons. Each promise is a key, and the promise’s reason is the associated value. Each time unhandledRejection is emitted, the promise and its rejection reason are added to the map. Each time rejectionHandled is emitted, the handled promise is removed from the map. As a result, possiblyUnhandledRejections grows and shrinks as events are called. The setInterval() call periodically checks the list of possible unhandled rejections and outputs the information to the console (in reality, you’ll probably want to do something else to log or otherwise handle the rejection). A map is used in this example instead of a weak map because you need to inspect the map periodically to see which promises are present, and that’s not possible with a weak map.
Although this example is specific to Node.js, browsers have implemented a similar mechanism for notifying developers about unhandled rejections.
Browsers also emit two events to help identify unhandled rejections. These events are emitted by the window object and are effectively the same as their Node.js equivalents:
unhandledrejection Emitted when a promise is rejected and no rejection handler is called within one turn of the event loop
rejectionhandled Emitted when a promise is rejected and a rejection handler is called after one turn of the event loop
Although the Node.js implementation passes individual parameters to the event handler, the event handler for these browser events receives an event object with the following properties:
type The name of the event ("unhandledrejection" or "rejectionhandled")
promise The promise object that was rejected
reason The rejection value from the promise
The other difference in the browser implementation is that the rejection value (reason) is available for both events. For example:
let rejected;
window.onunhandledrejection = function(event) {
console.log(event.type); // "unhandledrejection"
console.log(event.reason.message); // "Explosion!"
console.log(rejected === event.promise); // true
});
window.onrejectionhandled = function(event) {
console.log(event.type); // "rejectionhandled"
console.log(event.reason.message); // "Explosion!"
console.log(rejected === event.promise); // true
});
rejected = Promise.reject(new Error("Explosion!"));
This code assigns both event handlers using the DOM Level 0 notation of onunhandledrejection and onrejectionhandled. (You can also use addEventListener("unhandledrejection") and addEventListener("rejectionhandled") if you prefer.) Each event handler receives an event object containing information about the rejected promise. The type, promise, and reason properties are all available in both event handlers.
The code to keep track of unhandled rejections in the browser is very similar to the code for Node.js, too:
let possiblyUnhandledRejections = new Map();
// when a rejection is unhandled, add it to the map
window.onunhandledrejection = function(event) {
possiblyUnhandledRejections.set(event.promise, event.reason);
};
window.onrejectionhandled = function(event) {
possiblyUnhandledRejections.delete(event.promise);
};
setInterval(function() {
possiblyUnhandledRejections.forEach(function(reason, promise) {
console.log(reason.message ? reason.message : reason);
// do something to handle these rejections
handleRejection(promise, reason);
});
possiblyUnhandledRejections.clear();
}, 60000);
This implementation is almost exactly the same as the Node.js implementation. It uses the same approach of storing promises and their rejection values in a map and then inspecting them later. The only real difference is where the information is retrieved from in the event handlers.
Handling promise rejections can be tricky, but you’ve just begun to see how powerful promises can really be. It’s time to take the next step and chain several promises together.
At this point, promises may seem like little more than an incremental improvement over using some combination of a callback and the setTimeout() function, but there is much more to promises than meets the eye. Specifically, a number of ways are available to chain promises together to accomplish more complex asynchronous behavior.
Each call to then() or catch() actually creates and returns another promise. This second promise is resolved only when the first has been fulfilled or rejected. Consider this example:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
p1.then(function(value) {
console.log(value);
}).then(function() {
console.log("Finished");
});
This code outputs the following:
42
Finished
The call to p1.then() returns a second promise on which then() is called. The second then() fulfillment handler is only called after the first promise has been resolved. If you unchain this example, it looks like this:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = p1.then(function(value) {
console.log(value);
})
p2.then(function() {
console.log("Finished");
});
In this unchained version of the code, the result of p1.then() is stored in p2, and then p2.then() is called to add the final fulfillment handler. As you might have guessed, the call to p2.then() also returns a promise, but this example just doesn’t use that promise.
Promise chaining allows you to catch errors that may occur in a fulfillment or rejection handler from a previous promise. For example:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
p1.then(function(value) {
throw new Error("Boom!");
}).catch(function(error) {
console.log(error.message); // "Boom!"
});
In this code, the fulfillment handler for p1 throws an error. The chained call to the catch() method, which is on a second promise, is able to receive that error through its rejection handler. The same is true if a rejection handler throws an error:
let p1 = new Promise(function(resolve, reject) {
throw new Error("Explosion!");
});
p1.catch(function(error) {
console.log(error.message); // "Explosion!"
throw new Error("Boom!");
}).catch(function(error) {
console.log(error.message); // "Boom!"
});
Here, the executor throws an error and triggers the p1 promise’s rejection handler. That handler then throws another error that is caught by the second promise’s rejection handler. The chained promise calls are aware of errors in other promises in the chain.
NOTE
Always have a rejection handler at the end of a promise chain to ensure that you can properly handle any errors that may occur.
Another important aspect of promise chains is the ability to pass data from one promise to the next. I’ve shown how a value passed to the resolve() handler inside an executor is passed to the fulfillment handler for that promise, but you can continue passing data along a chain by specifying a return value from the fulfillment handler. For example:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
p1.then(function(value) {
console.log(value); // "42"
return value + 1;
}).then(function(value) {
console.log(value); // "43"
});
The fulfillment handler for p1 returns value + 1 when executed. Because value is 42 (from the executor), the fulfillment handler returns 43. That value is then passed to the fulfillment handler of the second promise, which outputs it to the console.
You could do the same thing with the rejection handler. When a rejection handler is called, it may return a value. If it does, that value is used to fulfill the next promise in the chain, as in the next example.
let p1 = new Promise(function(resolve, reject) {
reject(42);
});
p1.catch(function(value) {
// first fulfillment handler
console.log(value); // "42"
return value + 1;
}).then(function(value) {
// second fulfillment handler
console.log(value); // "43"
});
Here, the executor calls reject() with 42. That value is passed into the rejection handler for the promise, where value + 1 is returned. Even though this return value is coming from a rejection handler, it is still used in the fulfillment handler of the next promise in the chain. The failure of one promise can allow the recovery of the entire chain if necessary.
Returning primitive values from fulfillment and rejection handlers allows for the passing of data between promises, but what if you return an object? If the object is a promise, there’s an extra step that’s taken to determine how to proceed. Consider the following example:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = new Promise(function(resolve, reject) {
resolve(43);
});
p1.then(function(value) {
// first fulfillment handler
console.log(value); // 42
return p2;
}).then(function(value) {
// second fulfillment handler
console.log(value); // 43
});
In this code, p1 schedules a job that resolves to 42. The fulfillment handler for p1 returns p2, a promise already in the resolved state. The second fulfillment handler is called because p2 has been fulfilled. If p2 were rejected, a rejection handler (if present) would be called instead of the second fulfillment handler.
The important thing to recognize about this pattern is that the second fulfillment handler is not added to p2 but rather to a third promise. The second fulfillment handler is therefore attached to that third promise, making the previous example equivalent to this:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = new Promise(function(resolve, reject) {
resolve(43);
});
let p3 = p1.then(function(value) {
// first fulfillment handler
console.log(value); // 42
return p2;
});
p3.then(function(value) {
// second fulfillment handler
console.log(value); // 43
});
Here, it’s clear that the second fulfillment handler is attached to p3 rather than p2. This is a subtle but important distinction, because the second fulfillment handler will not be called if p2 is rejected. For instance:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = new Promise(function(resolve, reject) {
reject(43);
});
p1.then(function(value) {
// first fulfillment handler
console.log(value); // 42
return p2;
}).then(function(value) {
// second fulfillment handler
console.log(value); // never called
});
In this example, the second fulfillment handler is never called because p2 is rejected. However, you could attach a rejection handler instead:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = new Promise(function(resolve, reject) {
reject(43);
});
p1.then(function(value) {
// first fulfillment handler
console.log(value); // 42
return p2;
}).catch(function(value) {
// rejection handler
console.log(value); // 43
});
Now the rejection handler is called as a result of p2 being rejected. The rejected value 43 from p2 is passed into that rejection handler.
Returning thenables from fulfillment or rejection handlers doesn’t change when the promise executors are executed. The first defined promise will run its executor first, then the second promise executor will run, and so on. Returning thenables simply allows you to define additional responses to the promise results. You defer the execution of fulfillment handlers by creating a new promise within a fulfillment handler. For example:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
p1.then(function(value) {
console.log(value); // 42
// create a new promise
let p2 = new Promise(function(resolve, reject) {
resolve(43);
});
return p2
}).then(function(value) {
console.log(value); // 43
});
In this example, a new promise is created within the fulfillment handler for p1. That means the second fulfillment handler won’t execute until after p2 is fulfilled. This pattern is useful when you want to wait until a previous promise has been settled before triggering another promise.
Each example in this chapter so far has dealt with responding to one promise at a time. But sometimes you’ll want to monitor the progress of multiple promises to determine the next action. ECMAScript 6 provides two methods that monitor multiple promises: Promise.all() and Promise.race().
The Promise.all() method accepts a single argument, which is an iterable (such as an array) of promises to monitor, and returns a promise that is resolved only when every promise in the iterable is resolved. The returned promise is fulfilled when every promise in the iterable is fulfilled, as in this example:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = new Promise(function(resolve, reject) {
resolve(43);
});
let p3 = new Promise(function(resolve, reject) {
resolve(44);
});
let p4 = Promise.all([p1, p2, p3]);
p4.then(function(value) {
console.log(Array.isArray(value)); // true
console.log(value[0]); // 42
console.log(value[1]); // 43
console.log(value[2]); // 44
});
Each promise here resolves with a number. The call to Promise.all() creates promise p4, which is ultimately fulfilled when promises p1, p2, and p3 are fulfilled. The result passed to the fulfillment handler for p4 is an array containing each resolved value: 42, 43, and 44. The values are stored in the order in which the promises resolved, so you can match promise results to the promises that resolved to them.
If any promise passed to Promise.all() is rejected, the returned promise is immediately rejected without waiting for the other promises to complete:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = new Promise(function(resolve, reject) {
reject(43);
});
let p3 = new Promise(function(resolve, reject) {
resolve(44);
});
let p4 = Promise.all([p1, p2, p3]);
p4.catch(function(value) {
console.log(Array.isArray(value)) // false
console.log(value); // 43
});
In this example, p2 is rejected with a value of 43. The rejection handler for p4 is called immediately without waiting for p1 or p3 to finish executing. (They do finish executing; p4 just doesn’t wait.)
The rejection handler always receives a single value rather than an array, and the value is the rejection value from the promise that was rejected. In this case, the rejection handler is passed 43 to reflect the rejection from p2.
The Promise.race() method provides a slightly different take on monitoring multiple promises. This method also accepts an iterable of promises to monitor and returns a promise, but the returned promise is settled as soon as the first promise is settled. Instead of waiting for all promises to be fulfilled, like the Promise.all() method, the Promise.race() method returns an appropriate promise as soon as any promise in the array is fulfilled. For example:
let p1 = Promise.resolve(42);
let p2 = new Promise(function(resolve, reject) {
resolve(43);
});
let p3 = new Promise(function(resolve, reject) {
resolve(44);
});
let p4 = Promise.race([p1, p2, p3]);
p4.then(function(value) {
console.log(value); // 42
});
In this code, p1 is created as a fulfilled promise while the others schedule jobs. The fulfillment handler for p4 is then called with the value of 42 and ignores the other promises. The promises passed to Promise.race() are truly in a race to see which is settled first. If the first promise to settle is fulfilled, the returned promise is fulfilled; if the first promise to settle is rejected, the returned promise is rejected. Here’s an example with a rejection:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = Promise.reject(43);
let p3 = new Promise(function(resolve, reject) {
resolve(44);
});
let p4 = Promise.race([p1, p2, p3]);
p4.catch(function(value) {
console.log(value); // 43
});
Here, p4 is rejected because p2 is already in the rejected state when Promise.race() is called. Even though p1 and p3 are fulfilled, those results are ignored because they occur after p2 is rejected.
Just like other built-in types, you can use a promise as the base for a derived class. This allows you to define your own variation of promises to extend what built-in promises can do. For instance, suppose you want to create a promise that can use methods named success() and failure() in addition to the usual then() and catch() methods. You could create that promise type as follows:
class MyPromise extends Promise {
// use default constructor
success(resolve, reject) {
return this.then(resolve, reject);
}
failure(reject) {
return this.catch(reject);
}
}
let promise = new MyPromise(function(resolve, reject) {
resolve(42);
});
promise.success(function(value) {
console.log(value); // 42
}).failure(function(value) {
console.log(value);
});
In this example, MyPromise is derived from Promise and has two additional methods. The success() method mimics resolve() and failure() mimics the reject() method.
Each added method uses this to call the method it mimics. The derived promise functions the same as a built-in promise except now you can call success() and failure() if you want.
Because static methods are inherited, the MyPromise.resolve() method, the MyPromise.reject() method, the MyPromise.race() method, and the MyPromise.all() method are also present on derived promises. The last two methods behave the same as the built-in methods, but the first two are slightly different.
Both MyPromise.resolve() and MyPromise.reject() will return an instance of MyPromise regardless of the value passed because those methods use the Symbol.species property (see page 185) to determine the type of promise to return. If a built-in promise is passed to either method, the promise will be resolved or rejected, and the method will return a new MyPromise so you can assign fulfillment and rejection handlers. For example:
let p1 = new Promise(function(resolve, reject) {
resolve(42);
});
let p2 = MyPromise.resolve(p1);
p2.success(function(value) {
console.log(value); // 42
});
console.log(p2 instanceof MyPromise); // true
Here, p1 is a built-in promise that is passed to the MyPromise.resolve() method. The result, p2, is an instance of MyPromise where the resolved value from p1 is passed into the fulfillment handler.
If an instance of MyPromise is passed to the MyPromise.resolve() or MyPromise.reject() methods, it will just be returned directly without being resolved. In all other ways, these two methods behave like Promise.resolve() and Promise.reject().
In Chapter 8, I introduced generators and showed you how to use them for asynchronous task running, like this:
let fs = require("fs");
function run(taskDef) {
// create the iterator, make available elsewhere
let task = taskDef();
// start the task
let result = task.next();
// recursive function to keep calling next()
function step() {
// if there's more to do
if (!result.done) {
if (typeof result.value === "function") {
result.value(function(err, data) {
if (err) {
result = task.throw(err);
return;
}
result = task.next(data);
step();
});
} else {
result = task.next(result.value);
step();
}
}
}
// start the process
step();
}
// define a function to use with the task runner
function readFile(filename) {
return function(callback) {
fs.readFile(filename, callback);
};
}
// run a task
run(function*() {
let contents = yield readFile("config.json");
doSomethingWith(contents);
console.log("Done");
});
This implementation results in some pain points. First, wrapping every function in a function that returns a function is a bit confusing (even this sentence is confusing). Second, there is no way to distinguish between a function return value intended as a callback for the task runner and a return value that isn’t a callback.
You can greatly simplify and generalize this process by ensuring that each asynchronous operation returns a promise. Here’s one way you could simplify that task runner by using promises as a common interface for all asynchronous code:
let fs = require("fs");
function run(taskDef) {
// create the iterator
let task = taskDef();
// start the task
let result = task.next();
// recursive function to iterate through
(function step() {
// if there's more to do
if (!result.done) {
// resolve to a promise to make it easy
let promise = Promise.resolve(result.value);
promise.then(function(value) {
result = task.next(value);
step();
}).catch(function(error) {
result = task.throw(error);
step();
});
}
}());
}
// define a function to use with the task runner
function readFile(filename) {
return new Promise(function(resolve, reject) {
fs.readFile(filename, function(err, contents) {
if (err) {
reject(err);
} else {
resolve(contents);
}
});
});
}
// run a task
run(function*() {
let contents = yield readFile("config.json");
doSomethingWith(contents);
console.log("Done");
});
In this version of the code, a generic run() function executes a generator to create an iterator. It calls task.next() to start the task and recursively calls step() until the iterator is complete.
Inside the step() function, if there’s more work to do, then result.done is false. At that point, result.value should be a promise, but Promise.resolve() is called just in case the function in question didn’t return a promise. (Remember, Promise.resolve() just passes through any promise passed in and wraps any non-promise in a promise.) Next, a fulfillment handler is added that retrieves the promise value and passes the value back to the iterator. Then, result is assigned to the next yield result before the step() function calls itself.
A rejection handler stores any rejection results in an error object. The task.throw() method passes that error object back into the iterator, and if an error is caught in the task, result is assigned to the next yield result. Finally, step() is called inside catch() to continue.
This run() function can run any generator that uses yield to achieve asynchronous code without exposing promises (or callbacks) to the developer. In fact, because the return value of the function call is always converted to a promise, the function can even return something other than a promise. That means both synchronous and asynchronous methods work correctly when called using yield, and you never have to check that the return value is a promise.
The only concern is ensuring that asynchronous functions like readFile() return a promise that correctly identifies its state. For Node.js built-in methods, that means you’ll have to convert those methods to return promises instead of using callbacks.
Promises are designed to improve asynchronous programming in JavaScript by giving you more control and composability over asynchronous operations than events and callbacks can. Promises schedule jobs to be added to the JavaScript engine’s job queue for future execution, and a second job queue tracks promise fulfillment and rejection handlers to ensure proper execution.
Promises have three states: pending, fulfilled, and rejected. A promise starts in a pending state and becomes fulfilled on a successful execution or rejected on a failure. In either case, you can add handlers to indicate when a promise is settled. The then() method allows you to assign a fulfillment and rejection handler, and the catch() method allows you to assign only a rejection handler.
You can chain promises together in a variety of ways and pass information between them. Each call to then() creates and returns a new promise that is resolved when the previous one is resolved. Such chains can be used to trigger responses to a series of asynchronous events. You can also use Promise.race() and Promise.all() to monitor the progress of multiple promises and respond accordingly.
Asynchronous task running is easier when you combine generators and promises, because promises provide a common interface that asynchronous operations can return. You can then use generators and the yield operator to wait for asynchronous responses and respond appropriately.
Most new web APIs are being built on top of promises, and you can expect many more to follow suit in the future.