February 16, 2018
Promise
was added to javascript in the ECMAScript
2015 (ES6) standard. If you haven’t had a chance to use promises, I
recommend the MDN guide on using promises. One of the neat
things about promises is that they are designed to be
extensible. When calling methods that construct new promises, they use
this.constructor
.
We can verify this by checking the class of the new promise.
class MyPromise extends Promise {}
const p0 = MyPromise.resolve();
const p1 = p.then(() => {});
assert(p0 instanceof MyPromise);
assert(p1 instanceof MyPromise);
We’ve created our own sub-class of Promise
that we can call
combinators (e.g., then
) on and it returns an instance of that
sub-class!
This is where it gets fun. What if we want to extend the set of built-in
combinators to build a promise utility belt like Bluebird? Let’s
start with something simple. We want to add a simple method (log
) that logs
the value and passes the result unmodified.
class PromiseCombinator extends Promise {
log(log = console.log) {
return this.then(value => {
log(value);
return value;
});
}
}
Wait, this.then
? What’s this about? Since log
is a method on
PromiseCombinator.prototype
it can reference the
promise as this
to start chaining. Let’s break this code down.
We define a sub-class of Promise
called PromiseCombinator
.
class PromiseCombinator extends Promise {
We define a method on that class named log
. It accepts a single argument named
log
with a default value of console.log
.
log(log = console.log) {
Next, we want the value the promise resolves to and not just the promise. We use
.then
to chain a callback and add our log. We also return
the original value
to ensure that it doesn’t get dropped.
return this.then(value => {
log(value);
return value;
});
Finally, let’s see how it can be used.
PromiseCombinator.resolve("Hello, World!")
.log()
.then(value => assert.equal(value, "Hello, World!"));
Hello, World!
Creating a log
method was a good learning exercise, but isn’t especially
useful. A common task is to perform an action on a collection of values, e.g.
map
and filter
. Array.prototype
provides a good
template for implementation.
Array.prototype.map accepts a method that will be called for each
item in the collection and produces a new Array
with the results. The method
receives 3 arguments: the item, the position in the array, and the full array.
Let’s create a version of map
that executes an asynchronous task for each item
concurrently. This is where Promise.all
comes in handy.
Promise.all
accepts a collection of promises. It returns a promise that
resolves to a collection of values from each of those promises.
class PromiseCombinator extends Promise {
map(callback) {
return this.then(arr => {
const promises = arr.map(callback);
return Promise.all(promises);
});
}
}
Shouldn’t we use PromiseCombinator.all
? Won’t this cause us to return an
instance of Promise
instead of PromiseCombinator
? It turns out, it doesn’t
matter because the then
ensures we always return an instance of
this.constructor
.
The call to the built-in arr.map
creates an array of promises and the call to
Promise.all
waits for those promises to resolve.
const promises = arr.map(callback);
return Promise.all(promises);
Array.prototype.filter accepts a method called a
predicate. A predicate is a function that either evaluates to
true
or false
. Similar to map, the predicate is called for each item in the
collection and receives the item, the index and the array. The result of
filter
is a new array that only contains the items where the predicate
evaluated to a truthy value.
Once we’ve implemented an asynchronous map
, an asynchronous filter
follows
naturally. We can use map
to handle a predicate that returns a promise.
class PromiseCombinator extends Promise {
map(callback) {
return this.then(arr => {
const promises = arr.map(callback);
return Promise.all(promises);
});
}
filter(predicate) {
return this.then(arr => {
return this.map(predicate).then(values =>
arr.filter((_, i) => values[i])
);
});
}
}
First, let’s wait for the promise to resolve and then let’s use map
to execute
our predicate on each of the values. The order of these calls does not matter
and they can be inverted, but later we will need a copy of the original array.
return this.then(arr => {
return this.map(predicate).then(values =>
// also acceptable
return this.map(predicate).then(values => {
return this.then(arr =>
Lastly, we can perform a synchronous filter
on the original array using the
results from the map
.
arr.filter((_, i) => values[i]);
Putting it all together, we can produce a promise chain that filters to even
numbers and multiplies them by 2
.
PromiseCombinator.resolve([1, 2, 3, 4])
// Return a promise from the predicate for demonstration purposes
.filter(x => Promise.resolve(x % 2))
.map(x => x * 2)
.then(values => assert.deepEqual(values, [2, 6]));
Now we can chain multiple methods together, but the nested calls in filter
are
difficult to follow. Can we do better? Async/Await is a feature added
in ECMAScript 2017 (ES8). It utilizes the await
operator to
write asynchronous code more like synchronous code. We can re-write our filter
method async
.
class PromiseCombinator extends Promise {
map(callback) {
return this.then(arr => {
const promises = arr.map(callback);
return Promise.all(promises);
});
}
filter(predicate) {
return this.then(async arr => {
const values = await this.map(predicate);
return arr.filter((_, i) => values[i]);
});
}
}
PromiseCombinator.resolve([1, 2, 3, 4])
.filter(x => x % 2)
.map(x => x * 2)
.then(values => assert.deepEqual(values, [2, 6]));
The async
operator will ensure that the method always returns a promise and
enables the use of the await
operator.
return this.then(async arr => {
The await
operator will defer execution of the block until a value is yielded
from the promise.
const values = await this.map(predicate);
Great! But, if we can define a method as async
, why use .then
at all in our
methods? Can’t we define map
and filter
async
as well?
class PromiseCombinator extends Promise {
async map(callback) {
const arr = await this;
const promises = arr.map(callback);
return Promise.all(promises);
}
async filter(predicate) {
const arr = await this;
const values = await this.map(predicate);
return arr.filter((_, i) => values[i]);
}
}
PromiseCombinator.resolve([1, 2, 3, 4])
.filter(x => x % 2)
.map(x => x * 2)
.then(values => assert.deepEqual(values, [2, 6]));
TypeError: PromiseCombinator.resolve(...).filter(...).map is not a function
We can, but it turns out it breaks chaining. Any method defined as async
will always return a Promise
even if a sub-class is returned inside.
So far, we’ve only worked with unbounded parallelism. What if we wanted to
implement a forEach
method that operated serially? I.e., we call a method for
each item in a collection, waiting for the result to resolve before continuing
to the next item.
The await
will defer execution of the remainder of the block until the promise
has resolved. If it is called in a loop, the loop will pause on each iteration
to await the promise. We can combine this with
Array.prototype.entries
—a method of enumerating the indices
and values of an Array
—and have a fairly succinct way of expressing a serial
forEach
.
class PromiseCombinator extends Promise {
forEach(callback) {
return this.then(async arr => {
for (const [index, value] of arr.entries()) {
await Promise.resolve(callback(value, index, arr));
}
});
}
}
The only trick here is that callback
may or may not return a promise. Unlike
Promise.all
, the await
operator requires a promise. We can use
Promise.resolve
to wrap the result in a promise if it isn’t
a promise already.
One last improvement we can make to ergonomics is to add some static
methods to remove redundant calls to PromiseCombinator.resolve
.
class PromiseCombinator extends Promise {
static map(value, callback) {
return PromiseCombinator.resolve(value).map(callback);
}
static filter(value, predicate) {
return PromiseCombinator.resolve(value).filter(predicate);
}
static forEach(value, callback) {
return PromiseCombinator.resolve(value).forEach(callback);
}
map(callback) {
return this.then(arr => {
const promises = arr.map(callback);
return Promise.all(promises);
});
}
filter(predicate) {
return this.then(async arr => {
const values = await this.map(predicate);
return arr.filter((_, i) => values[i]);
});
}
forEach(callback) {
return this.then(async arr => {
for (const [index, value] of arr.entries()) {
await Promise.resolve(callback(value, index, arr));
}
});
}
}
PromiseCombinator.filter([1, 2, 3, 4], x => x % 2)
.map(x => x * 2)
.then(values => assert.deepEqual(values, [2, 6]));
It was fun learning about promises and how they can be extended with more powerful combinators. For a more complete collection of combinators, you may be interested in my library extends-promise.
Hi! I'm K.J. and I like to make things. I'm interested in Rust, JS, Kubernetes, distributed systems and neat technology. Check me out on Github.