Promises, Thenables, and Lazy-Evaluation: What, Why, How
It’s the start of a new year, and while lots of folks are promising to be more active, I’m going to show you how to make Promise
s to be lazier: JavaScript Promise
s, that is.
It will make more sense in a moment.
First, let’s look at a basic Promise
example. Here I have a function called sleep that takes a time in milliseconds and a value. It returns a promise that will execute a setTimeout
for the number of milliseconds that we should wait, then the Promise resolves with the value.
/** * @template ValueType * @param {number} ms * @param {ValueType} value * @returns {Promise<ValueType>} */ function sleep(ms, value) { return new Promise((resolve) => { setTimeout(() => resolve(value), ms); }); }
It works like this:
We can await the sleep
function with the arguments 1000
and 'Yawn & stretch'
, and after one second the console
will log the string, ‘Yawn & stretch’.
There’s nothing too special about that. It probably behaves as you would expect, but it gets a little weird if we store it as a variable to await
later on, rather than await
ing the returned Promise
right away.
const nap = sleep(1000, 'Yawn & stretch')
Now let’s say we do some other work that takes time (like typing out the next example), and then await
the nap
variable.
You might expect a one-second delay before resolving, but in fact, it resolves immediately. Anytime you create a Promise
, you instantiate whatever asynchronous functionality it’s responsible for.
In our example, the moment we define the nap
variable, the Promise
gets created which executes the setTimeout
. Because I’m a slow typer, the Promise
will be resolved by the time we await
it.
In other words, Promise
s are eager. They do not wait for you to await
them.
In some cases, this is a good thing. In other cases, it could lead to unnecessary resource use. For those scenarios, you may want something that looks like a Promise
, but uses lazy evaluation to only instantiate when you need it.
Before we continue, I want to show you something interesting.
Promise
s are not the only things that can be await
ed in JavaScript. If we create a plain Object
with a .then()
method, we can actually await
that object just like any Promise
.
This is kind of weird, but it also allows us to create different objects that look like Promise
s, but aren’t. These objects are sometimes called “Thenables."
With that in mind, let’s create a new class called LazyPromise
that extends the built-in Promise
constructor. Extending Promise isn’t strictly necessary, but it makes it appear more similar to a Promise
using things like instanceof
.
class LazyPromise extends Promise { /** @param {ConstructorParameters<PromiseConstructor>[0]} executor */ constructor(executor) { super(executor); if (typeof executor !== 'function') { throw new TypeError(`LazyPromise executor is not a function`); } this._executor = executor; } then() { this.promise = this.promise || new Promise(this._executor); return this.promise.then.apply(this.promise, arguments); } }
The part to focus on is the then()
method. It hijacks the default behavior of a standard Promise
to wait until the .then()
method is executed before creating a real Promise
. This avoids instantiating the asynchronous functionality until you actually call for it, and it works whether you explicitly call .then()
or use await
.
Now, let’s see what happens if we replace the Promise
in the original sleep
function with a LazyPromise
. Once again, we’ll assign the result to a nap
variable.
function sleep(ms, value) { return new LazyPromise((resolve) => { setTimeout(() => resolve(value), ms); }); } const nap = sleep(1000, 'Yawn & stretch')
Then we take our time to type out the await nap
line and execute it.
This time, we see a one-second delay before the Promise
resolves, regardless of how much time passed since the variable was created. (Note that this implementation only creates the new Promise
once and references it in subsequent calls. So if we were to await
it again, it would resolve immediately like any normal Promise
.)
Of course, this is a trivial example that you probably won’t find in production code, but there are many projects that use lazy-evaluated Promise
-like objects. Probably the most common example is with database ORMs and query builders like Knex.js or Prisma.
Consider the pseudo-code below. It’s inspired by some of these query builders:
const query = db('user') .select('name') .limit(10) const users = await query
We create a database query that goes to the "user"
table and selects the first ten entries and returns their names. In theory, this would work fine with a regular Promise
.
But what if we wanted to modify the query based on certain conditions like query string parameters? It would be nice to be able to continue modifying the query before ultimately awaiting the Promise
.
const query = db('user') .select('name') .limit(10) if (orderBy) { query.orderBy(orderBy) } if (limit) { query.limit(limit) } if (id) { query.where({ id: id }) } const users = await query
If the original database query was a standard Promise
, it would eagerly instantiate the query as soon as we assigned the variable, and we wouldn’t be able to modify it later on. With lazy evaluation, we can write code like this that’s easier to follow, improves the developer experience, and only executes the query once when we need it.
That’s one example where lazy evaluation is great. It might also be useful for things like building, modifying, and orchestrating HTTP requests.
Lazy Promise
s are very cool for the right use cases, but that’s not to say that they should replace every Promise
. In some cases, it’s beneficial to instantiate eagerly and have the response ready as soon as possible. This is another one of those “it depends” scenarios. But the next time someone asks you to make a Promise
, consider being lazy about it ( ͡° ͜ʖ ͡°).
Thank you so much for reading. If you liked this article, please share it.