In my previous post, we created a promise implementation from scratch. We didn't however, make this implementation compliant with the A+ promise specification.

Here be dragons: This stuff is tricky & can be a little mind-bending. You've been warned!

Jump to the final code to see our final A+ promise implementation.

Let's run our implementation through a series of tests to see how close to compliance we are:

264 tests passed
608 tests failed

Umm, that's not great... fail

The promise A+ spec is incredibly specific on behaviour, this is intentional. Because there are so many implementations of promises, and their nature as a 'core' tool, it is important that promises behave very consistently across platforms. There is no undefined behaviour in the promise A+ spec. Let's modify out original implementation to cater for 'the weird bits' of promises and aim for 100% passing tests from the A+ spec.

Catching synchronous errors in our executor

Our first issue, is executor functions that throw while running.

new MyPromise((resolve, reject) => {
  throw new Error("Uh-oh");
});

To fix that, we can simply wrap the call of our executor function in a try/catch

class MyPromise {
  ...

  constructor(executor) {
    // all promises start their life pending & with no fulfilled value
    this.#state = "PENDING";
    this.#fulfilledValue = undefined;

    this.#handlers = [];

    // immediately run our executor function
    // provide it 2 functions (resolve, reject) to call when a result is ready
    try {
      executor(
        (value) => this.#resolveWithValue(value),
        (rejectReason) => this.#rejectWithReason(rejectReason)
      );
    } catch (e) {
      // make sure we handle our executor function unexpectedly throwing
      this.#rejectWithReason(e);
    }
  }

  ...
}

Resolving/rejecting multiple times

Points 2.1.2 and 2.1.3 in the specification require that we only allow a promise to resolve or reject once. Adding checks to ensure resolving or rejecting when the promise is not in the 'pending' state:

class MyPromise {
  ...

  #resolveWithValue(value) {
    // a promise can only resolve or reject once
    // if we aren't pending, we must have already resolved or rejected
    if (this.#state !== "PENDING") {
      return;
    }

    ...
  }

  #rejectWithReason(rejectReason) {
    // a promise can only resolve or reject once
    // if we aren't pending, we must have already resolved or rejected
    if (this.#state !== "PENDING") {
      return;
    }

    ...
  }

  ...
}

Resolving with self

if resolve() is called with a promise, we wait for that sub promise to resolve before resolving the outer promise. We covered that in 'the core bits', however there is an edge case we missed here. If we call resolve() with a reference to ourself, our promise will hang forever... because it is waiting for itself to resolve before it can resolve.

The spec solves this problem by simply rejecting if our executor tries to resolve with a self reference.

class MyPromise {
  ...

  #resolveWithValue(value) {
    // a promise can only resolve or reject once
    // if we aren't pending, we must have already resolved or rejected
    if (this.#state !== "PENDING") {
      return;
    }
    if (value === this) {
      this.#rejectWithReason(new TypeError('Cannot resolve promise with itself'));
    }

    ...
  }

    ...
}

Microtasks

I could explain microtasks, but I'd probably do a poor job. So I'll quote MDN instead

A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.

Essentially, we want to run any code we had lined up before we fire off our callbacks. Updating our implementation to call every callback via queueMicrotask()

class MyPromise {
  ...

  #resolveWithValue(value) {
    ...

    // call every handler, to call .then handlers & resolve .then sub promises
    for (const handler of this.#handlers) {
      queueMicrotask(handler);
    }
  }

  #rejectWithReason(rejectReason) {
    ...

    // call every handler, to call .then handlers & resolve .then sub promises
    for (const handler of this.#handlers) {
      queueMicrotask(handler);
    }
  }

  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const handleSettle = () => {
        if (this.#state === "FULFILLED") {
          if (onFulfilled) {
            // we have a handler function for this!
            // call it, then resolve with it's return value
            // e.g.
            // doThing() <-- doThing() just resolved
            //   .then(storeResult) <-- call storeResult when doThing state === "FULFILLED" (you are here)
            //   .then(notifyUser) <-- another .then handler is attached to the promise returned by .then(storeResult)
            queueMicrotask(() => resolve(onFulfilled(this.#fulfilledValue)));
          } else {
            queueMicrotask(() => resolve(this.#fulfilledValue));
          }
        } else { // rejected
          if (onRejected) {
            queueMicrotask(() => resolve(onRejected(this.#fulfilledValue)));
          } else {
            queueMicrotask(() => reject(this.#fulfilledValue));
          }
        }
      };

      ...
    });
  }

  ...
}

Not so bad! A lot of code but only a few lines in there we actually had to change.

Catching errors in then handlers

Similarly to how we need to handle errors from our executor function, we should also catch errors in our then handler functions. If a handler fails, we want to reject the promise with the error the handler threw. We want this same behaviour for both our onFulfilled and onRejected handlers, so lets abstract calling handlers, so we can handle errors consistently.

class MyPromise {
  ...

  then(onFulfilled, onRejected) {
   return new MyPromise((resolve, reject) => {
      // wrapper function to safely call either our onFulfilled or onRejected function
      // once handler function has returned, resolve our .then promise automatically
      // catches all errors & automatically calls reject if anything fails
      const callHandler = (handler) => {
        // spec requires these to be called after the current task queue has completed
        queueMicrotask(() => {
          try {
            resolve(handler(this.#fulfilledValue));
          } catch (e) {
            reject(e);
          }
        });
      };

      // we only have one function here for handling both fulfillment & rejection
      // check what state the promise is in when this gets called, and call the appropriate handler function
      // once the handler function has returned, resolve our .then promise (see callHandler above)
      // if the handler function isn't provided or callable,
      // just resolve our .then promise without calling any handler
      const handleSettle = () => {
        if (this.#state === "FULFILLED") {
          if (onFulfilled) {
            // we have a handler function for this!
            // call it, then resolve with it's return value
            // e.g.
            // doThing() <-- doThing() just resolved
            //   .then(storeResult) <-- call storeResult when doThing state === "FULFILLED" (you are here)
            //   .then(notifyUser) <-- another .then handler is attached to the promise returned by .then(storeResult)
            callHandler(onFulfilled);
          } else {
            resolve(this.#fulfilledValue);
          }
        } else { // rejected
          if (onRejected) {
            callHandler(onRejected);
          } else {
            reject(this.#fulfilledValue);
          }
        }
      };

      ...
    }
  }

  ...
}

Thenables

We are currently checking if our executor is calling resolve() with an instance of MyPromise. However, the spec is actually a little more flexible when it comes to resolving with a sub promise. A "thenable" is defined as

an object or function that defines a then method

We should be treating any 'thenable' object as a promise, rather than just instances of our own promise class. This means you could technically chain promises from different implementations... although for the sanity of your fellow developers please don't do that.

This contains many edge cases, what if .then is actually a getter on our object, and triggering that getter crashes?! Weirdly the spec also requires that the '.then' property is only fetched once. I'm not sure what the purpose of this is, but I guess it again removes any possible variance between implementations.

class MyPromise {
  ...

  #resolveWithValue(value) {
    ...

    try {
      // make sure we only access the accessor once as required by the spec
      const then = value && value.then;

      // check if the value we have 'resolved' with is a promise
      // if so, don't fulfill with this value, instead, wait for it to resolve
      // then try again to resolve with the value of this sub promise
      if (typeof value === "object" && typeof then === "function") {
        // attach a 'then' handler to this promise that we were resolved with
        // once it returns, call resolve/reject again with the value it fulfills to
        then.call(value,
          (result) => {
            this.#resolveWithValue(result);
          },
          (rejectReason) => {
            this.#rejectWithReason(rejectReason);
          },
        );
      } else {
        // update our local state & store the value we fulfilled with
        this.#state = "FULFILLED";
        this.#fulfilledValue = value;

        // call every handler, to call .then handlers & resolve .then sub promises
        for (const handler of this.#handlers) {
          queueMicrotask(handler);
        }
      }
    } catch (e) {
      // catch if fetching .then or calling .then throws unexpectedly
      this.#rejectWithReason(e);
    }
  }

  ...
}

thenables resolving/rejecting multiple times...

Argg, we've re-introduced a problem that we fixed earlier! If our sub promise resolves, then throws we will reject while we are in the process of resolving (possibly waiting for a sub promise to resolve). Let's add a flag to deal with that edge case:

class MyPromise {
  ...

  #resolveWithValue(value) {
    ...

    let hasCalledResolve = false;

    try {
      // make sure we only access the accessor once as required by the spec
      const then = value && value.then;

      // check if the value we have 'resolved' with is a promise
      // if so, don't fulfill with this value, instead, wait for it to resolve
      // then try again to resolve with the value of this sub promise
      if (typeof value === "object" && typeof then === "function") {
        // attach a 'then' handler to this promise that we were resolved with
        // once it returns, call resolve/reject again with the value it fulfills to
        then.call(value,
          (result) => {
            if (!hasCalledResolve) {
              // ensure we can never resolve/reject multiple times
              hasCalledResolve = true;
              this.#resolveWithValue(result);
            }
          },
          (rejectReason) => {
            if (!hasCalledResolve) {
              this.#rejectWithReason(rejectReason);
            }
          },
        );
      } else {
        // update our local state & store the value we fulfilled with
        this.#state = "FULFILLED";
        this.#fulfilledValue = value;

        // call every handler, to call .then handlers & resolve .then sub promises
        for (const handler of this.#handlers) {
          queueMicrotask(handler);
        }
      }
    } catch (e) {
      // catch if fetching .then or calling .then throws unexpectedly
      if (!hasCalledResolve) {
        this.#rejectWithReason(e);
      }
    }
  }

  ...
}

Dealing with .then() being called with non function values

Almost there, finally the spec calls that we treat any non-function value provided as an onFulfilled/onRejected handler as undefined. Updating our logic in .then() to check if our param has a '.call' property.

class MyPromise {
  ...

  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      ...
      const handleSettle = () => {
        if (this.#state === "FULFILLED") {
          if (onFulfilled && onFulfilled.call) {
            ...
          }
        } else { // rejected
          if (onRejected && onRejected.call) {
            ...
          } else {
            ...
          }
        }
      };

      ...
    });
  }

  ...

Putting it all together

class MyPromise {
  // use JavaScript private variables to hold state
  #state; // "PENDING", "FULFILLED" or "REJECTED"
  #fulfilledValue; // the value we FULFILL or REJECT with
  #handlers; // array of functions to call when we FULFILL or REJECT

  constructor(executor) {
    // all promises start their life pending & with no fulfilled value
    this.#state = "PENDING";
    this.#fulfilledValue = undefined;

    this.#handlers = [];

    // immediately run our executor function
    // provide it 2 functions (resolve, reject) to call when a result is ready
    try {
      executor(
        (value) => this.#resolveWithValue(value),
        (rejectReason) => this.#rejectWithReason(rejectReason)
      );
    } catch (e) {
      // make sure we handle our executor function unexpectedly throwing
      this.#rejectWithReason(e);
    }
  }

  #resolveWithValue(value) {
    // a promise can only resolve or reject once
    // if we aren't pending, we must have already resolved or rejected
    if (this.#state !== "PENDING") {
      return;
    }
    if (value === this) {
      this.#rejectWithReason(new TypeError('Cannot resolve promise with itself'));
    }

    let hasCalledResolve = false;

    try {
      // make sure we only access the accessor once as required by the spec
      const then = value && value.then;

      // check if the value we have 'resolved' with is a promise
      // if so, don't fulfill with this value, instead, wait for it to resolve
      // then try again to resolve with the value of this sub promise
      if (typeof value === "object" && typeof then === "function") {
        // attach a 'then' handler to this promise that we were resolved with
        // once it returns, call resolve/reject again with the value it fulfills to
        then.call(value,
          (result) => {
            if (!hasCalledResolve) {
              // ensure we can never resolve/reject multiple times
              hasCalledResolve = true;
              this.#resolveWithValue(result);
            }
          },
          (rejectReason) => {
            if (!hasCalledResolve) {
              this.#rejectWithReason(rejectReason);
            }
          },
        );
      } else {
        // update our local state & store the value we fulfilled with
        this.#state = "FULFILLED";
        this.#fulfilledValue = value;

        // call every handler, to call .then handlers & resolve .then sub promises
        for (const handler of this.#handlers) {
          queueMicrotask(handler);
        }
      }
    } catch (e) {
      // catch if fetching .then or calling .then throws unexpectedly
      if (!hasCalledResolve) {
        this.#rejectWithReason(e);
      }
    }
  }

  #rejectWithReason(rejectReason) {
    // a promise can only resolve or reject once
    // if we aren't pending, we must have already resolved or rejected
    if (this.#state !== "PENDING") {
      return;
    }

    // update our local state & store the value we rejected with
    this.#state = "REJECTED";
    this.#fulfilledValue = rejectReason;

    // call every handler, to call .then handlers & resolve .then sub promises
    for (const handler of this.#handlers) {
      queueMicrotask(handler);
    }
  }

  then(onFulfilled, onRejected) {
    // calling .then always returns a new promise
    // this allows us to chain promises together
    // e.g.
    // fetchData()
    //   .then(parseData)
    //   .then(storeData)
    //   .then(notifyUser)

    return new MyPromise((resolve, reject) => {
      // wrapper function to safely call either our onFulfilled or onRejected function
      // once handler function has returned, resolve our .then promise automatically
      // catches all errors & automatically calls reject if anything fails
      const callHandler = (handler) => {
        // spec requires these to be called after the current task queue has completed
        queueMicrotask(() => {
          try {
            resolve(handler(this.#fulfilledValue));
          } catch (e) {
            reject(e);
          }
        });
      };

      // we only have one function here for handling both fulfillment & rejection
      // check what state the promise is in when this gets called, and call the appropriate handler function
      // once the handler function has returned, resolve our .then promise (see callHandler above)
      // if the handler function isn't provided or callable,
      // just resolve our .then promise without calling any handler
      const handleSettle = () => {
        if (this.#state === "FULFILLED") {
          if (onFulfilled && onFulfilled.call) {
            // we have a handler function for this!
            // call it, then resolve with it's return value
            // e.g.
            // doThing() <-- doThing() just resolved
            //   .then(storeResult) <-- call storeResult when doThing state === "FULFILLED" (you are here)
            //   .then(notifyUser) <-- another .then handler is attached to the promise returned by .then(storeResult)
            callHandler(onFulfilled);
          } else {
            resolve(this.#fulfilledValue);
          }
        } else { // rejected
          if (onRejected && onRejected.call) {
            callHandler(onRejected);
          } else {
            reject(this.#fulfilledValue);
          }
        }
      };

      // check to see if we have already resolved, or are still pending
      // if still pending, store the handler function on our local #handlers array to be called later
      // if already settled, immediately call handleSettle()
      if (this.#state === "PENDING") {
        // not settled yet, store handler so we can call once resolved
        this.#handlers.push(handleSettle);
      } else {
        // already settled
        handleSettle();
      }
    });
  }

  // convenience method, attach reject handler
  catch(onRejected) {
    this.then(undefined, onRejected);
  }

  // convenience method, handler to run either resolve or reject
  finally(onFinally) {
    this.then(onFinally, onFinally);
  }
}

We got there, if you made it this far, well done!

Let's re-run our test suite and see how we go:

872 tests passed
0 tests failed

Finish line