Promise Basics
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, like this:
// readFile promises to complete at some point in the future
let promise = readFile("example.txt");
In this code, readFile()
doesn’t actually 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 lifecycle plays out.
The Promise Lifecycle
Each promise goes through a short lifecycle 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 last 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.
I> Any object that implements the then()
method in this way is called a thenable. All promises are thenables, but not all thenables are 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 both 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 intent behind then()
and catch()
is for you to use them in combination to properly handle the result of asynchronous operations. This system is better than events and callbacks because it makes whether the operation succeeded or failed completely clear. (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 ready. Rejection handlers work the same way.
I> 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 so long as you understand how job queues work in general.
Creating Unsettled Promises
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, while 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 from 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, then 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 500ms have passed
setTimeout(function() {
console.log("Timeout");
}, 500);
console.log("Hi!");
This code schedules a job to be added to the job queue after 500ms. The two console.log()
calls produce the following output:
Hi!
Timeout
Thanks to the 500ms 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, as 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 console.log("Hi!")
line, it doesn’t actually execute until later (unlike the executor). That’s because fulfillment and rejection handlers are always added to the end of the job queue after the executor has completed.
Creating Settled Promises
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, then it doesn’t make sense to schedule a job that simply passes a value to the resolve()
function. Instead, there are two methods that create settled promises given a specific value.
Using Promise.resolve()
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.
Using Promise.reject()
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.
I> If you pass a promise to either the Promise.resolve()
or Promise.reject()
methods, the promise is returned without modification.
Non-Promise Thenables
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 that 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.
The same process can be used 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 last except that 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. A lot of libraries used thenables prior to promises being introduced in ECMAScript 6, so the ability to convert thenables into formal promises is important for backwards-compatibility with previously existing libraries. When you’re unsure if 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.
Executor Errors
If an error is thrown inside an executor, then 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. There is an implicit try-catch
inside every executor such that the error is caught and then passed to the rejection handler. The previous example is equivalent to:
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 handles catching 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 in the use of promises, and JavaScript environments address it by providing hooks for catching rejected promises.