February 10, 2020

Part I: Promise/A+ spec implementation

BACK TO POSTS

Promises have been around for a while now, but have you ever wondered how do they work? I would argue that knowing how to use something is as valuable as knowing how it works internally. In this 3 part series we're going to implement HelloPromise, fully compliant with Promises/A+ spec.

If you want to get to the meat right away, you can find the implementation here.

No Bulls**t

Before I've decided to write this document, I've been lurking the web to figure out if this topic has even been touched. Unfortunately majority of the resources I've read implement Thenables, not Promises. To call something a Promise, it must be compliant with Promises/A+ spec. If you want to know how actual Promises are implemented, you're in the right place.

You pompous...

Before you label me with a pompous jerk card, please let me explain. I understand that these Thenables were made to show, in the most basic terms, how Promises operate. In my opinion, though, it wouldn't hurt to at least let your readers know about it. Just make it clear, be honest. It can save your reader a lot of confusion in the future.

This implementation is complete ✌🏻

Some of the resources I've read didn't include a working implementation, only slick snippets of disconnected code, no source included. You have to stitch this thing up like a Frankenstein monster, then test it, only to find out it doesn't even pass 2.1.2.1 spec section(the most basic one).

HelloPromise is fully compliant with the spec AND published.

Not a trivial problem

See the main title of this section? I'm not going to lie to you that understanding how Promises work is a piece of cake. Hell! First exposure to Promises can be confusing even on the user end - hence so many questions on all sorts of forums.

Prerequisites

Higher-order functions and intuition of how to use Promises are a must.

Promise 101

Promises are a bridge between an asynchronous and synchronous world. They allow us to compose asynchronous functions.

State machine

In the most basic terms, Promises are state machines with 3 possible states.

  1. Pending - neither resolved or rejected
  2. Rejected - something went wrong
  3. Resolved - everything went right

If the Promise is not pending, we say that the Promise is settled.

Thenable

Every Promise is a Thenable object - it implements then function. In fact, it's the only thing that Promises/A+ spec regulates. We can implement it however we want as long as it's following the rules described in the spec.

Hello, Promise 👋🏻

Obviously, we're going to implement our Promise according to Promises/A+ spec. Because this series is kind of, sort of like a tutorial, we're going to call our implementation HelloPromise deriving from Hello, World type of material.

1. Design

Before we begin, we have serious decisions to make. As I've mentioned Promises can be thought of as state machines. Let's implement our HelloPromise as an object. It must have a public then method, but we're also going to include two static methods - resolve and reject(we need them for testing). On top of that, I propose that we use ES6 with class properties. So summarizing:

HelloPromise will be implemented in ES6 with class properties.

HelloPromise class must have two static methods - resolve and reject.

HelloPromise objects must have a public then method.

2. Testing

For tests, we're going to use promises-tests mocha test suite. Passing all the tests means we've just implemented a fully compliant Promise/A+ spec.

Because original repo still didn't update its mocha dependency, npm complains about some security issues - I've fixed it in my fork, which you can install with npm:

$ npm i -D GrayTable/promises-tests

3. Envoirnment

Combining everything we've decided so far, we need the following packages:

// the tests
=> promises-aplus-tests
// to transpile our ES6 code
=> @babel/core
=> @babel/cli
=> @babel/plugin-proposal-class-properties
=> @babel/preset-env

Setup

I've already prepared a ready to use env for the purposes of this post.

$ git clone https://github.com/GrayTable/hellopromise-env

It supports two commands.

# build ES5 files from ES6 source
$ npm run build
# run testsuite
$ npm run test

It also includes every step of the implementation in parts directory.

Outline

In this section we will define an outline of HelloPromise implementation.

States

Promise has three possible states.

// project_root/src/HelloPromise.js
// Possible states
const states = {
pending: 'pending', // not settled
rejected: 'rejected', // settled
resolved: 'resolved', // settled
}

Helpers

During the implementation, we're going to check types quite a lot. We're will also have to execute some functions asynchronously. Let's create a few helper functions to make our code more readable.

// Helpers
const isFunction = value => typeof value === 'function';
const isObject = value => typeof value === 'object';
const isPromise = value => value instanceof HelloPromise;
const runAsync = fn => setTimeout(fn, 0);

Constructor

Some theory first. Promises are constructed with higher-order function called executor. We've already established that Promises are state machines, our initial state will be pending. Promises also hold some value that is being set when the Promise becomes settled. It means that initially our value is not set, it is null.

class HelloPromise {
constructor(executor) {
this.value = null; // not set until Promise settles
this.state = states.pending; // initially Promise is not settled
}
}

But what about our executor function? It can be defined however you like:

executor = (resolve, reject) => {
// should make our Promise resolve with value 5
// after one second
setTimeout(_ => resolve(5), 1000)
}

Looking at the above example, how do we achieve this behaviour? In the constructor we can call the executor function with internal instance methods, that manipulate Promises state and value.

class HelloPromise {
constructor(executor) {
this.value = null; // not set until Promise settles
this.state = states.pending; // initially Promise is not settled
try {
executor(this._resolver, this._reject);
}
catch (reason) {
this._reject(reason);
}
}
}

_transition

We need some way to change our Promises state and value. Let's create a _transition method that handles it.

class HelloPromise {
constructor(executor) { ... }
_transition = (state, value) => {
this.state = state;
this.value = value;
}
}

_fulfill and _reject

When it comes to _transition method there are basically only two options, we will either set our state to resolved and assign a value, or set our state to rejected and assign a value. To save us some verbosity, let's add _fulfill and _reject methods that abstract the state variable.

class HelloPromise {
constructor(executor) { ... }
_transition = (state, value) => { ... }
_fulfill = value => this._transition(states.resolved, value);
_reject = reason => this._transition(states.rejected, reason);
}

_resolver

I'm jumping ahead just a little bit here. Seeing how _resolver method is implemented you could think - why? Different types of values have different resolving procedures(2.3 spec section). Our _resolver should make a decision on which procedure to use. For now let's ignore this requirement and say that _resolver simply fulfills the promise with value being passed in.

class HelloPromise {
constructor(executor) { ... }
_transition = (state, value) => { ... }
_fulfill = value => { ... }
_reject = reason => { ... }
_resolver = value => this._fulfill(value)
}

then, static resolve, static reject

Promise is a Thenable object - it has public then method. For our tests we need to also expose resolve and reject static methods. We'll skip then implementation for now.

But what are the static methods doing? Static resolve method returns new Promise object resolved with some value. Static reject method returns new Promise that has been rejected with some value. It allows use to write:

// hello is HelloPromise object with resolved
// state and value set to 1
const hello = new HelloPromise.resolve(1);
class HelloPromise {
constructor(executor) { ... }
_transition = (state, value) => { ... }
_fulfill = value => { ... }
_reject = reason => { ... }
_resolver = value => { ... }
then = (onFulfilled, onRejected) => null;
static resolve = value => new HelloPromise(resolve => resolve(value));
static reject = reason => new HelloPromise((_, reject) => reject(reason));
}

We're done with the outline!

What's next?

Today we've talked a little bit about what is required to call something a Promise. set up our env, and finally implemented the basic outline of HelloPromise class. In the next part we'll make it comply with 2.1 and 2.2 sections of the Promises/A+ spec.

Source code

Source code for each part of this series is available here.

Next part

Part II: Promise/A+ spec implementation