Promises, Thenables, & Lazy-evaluation: What, Why, How

Promises, Thenables, & 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 Promises to be more lazy…JavaScript Promises, that is.

It’ll 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:

JavaScript console with the code, "await sleep(1000, 'Yawn & stretch')". Then after one second, "'Yawn & stretch'"

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 awaiting 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.

Typing into the JavaScript console, "await nap" and immediately seeing the response "'Yawn & stretch'"

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, Promises 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.

Promises are not the only things that can be awaited in JavaScript. If we create a plain Object with a .then() method, we can actually await that object just like any Promise.

JavaScript console with the text, "await { then: () => console.log('🙃') }" followed by, "🙃".

This is kind of weird, but it also allows us to create different objects that look like Promises, 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.

Typing into the JavaScript console, "await nap" and after one second delay, seeing the response "'Yawn & stretch'"

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 Promises 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. It's one of the best ways to support me. You can also sign up for my newsletter or follow me on Twitter if you want to know when new articles are published.


Originally published on austingil.com.

Did you find this article valuable?

Support Austin Gil by becoming a sponsor. Any amount is appreciated!