Tuesday, August 20, 2013

Promises/A+ - understanding the spec through implementation

NB: This is for promises/A+ v1. The spec has since moved to v1.1. The below is still a good introduction. There are now slides available with an implementation of v1.1.


What we're going to do is create a promises/A+ implementation based on http://promises-aplus.github.io/promises-spec/. By doing this hopefully we'll get a deeper understanding of just how promises work. I'll call this Aplus and put it up on github under https://github.com/rhysbrettbowen/Aplus

First some boilerplate. Let's make Aplus an object:

Aplus = {};

Promise States

from http://promises-aplus.github.io/promises-spec/#promise_states there are three states: pending, fulfilled and rejected. It does not state the value of these states, so let's enumerate them:

var State = {
 PENDING: 0,
 FULFILLED: 1,
 REJECTED: 2
};

var Aplus = {
 state: State.PENDING
};

you will see that I've also put in the default state for our promise as pending.

now we need to be able to transition from a state. There are some rules around what transitions are allowed - mostly that when we transition from pending to any other state we can't transition again. Also when transitioning to a fulfilled state we need a value, and a reason for rejected.

according to the terminology http://promises-aplus.github.io/promises-spec/#terminology a value can be anything including undefined and a reason is any value that indicates why a promise was rejected. That last definition is a little blurry - can "undefined" indicate why something was rejected? I'm going to say no and only accept non-null values. If anything doesn't work then I'll throw an error. So let's create a "chageState" method that handles the checking for us:

var State = {
 PENDING: 0,
 FULFILLED: 1,
 REJECTED: 2
};

var Aplus = {
 state: State.PENDING,
 changeState: function(state, value) {

  // catch changing to same state (perhaps trying to change the value)
  if ( this.state == state ) {
   throw new Error("can't transition to same state: " + state);
  }

  // trying to change out of fulfilled or rejected
  if ( this.state == State.FULFILLED ||
    this.state == State.REJECTED ) {
   throw new Error("can't transition from current state: " + state);
  }

  // if second argument isn't given at all (passing undefined allowed)
  if ( state == State.FULFILLED &&
    arguments.length < 2 ) {
   throw new Error("transition to fulfilled must have a non null value");
  }

  // if a null reason is passed in
  if ( state == State.REJECTED &&
    value == null ) {
   throw new Error("transition to rejected must have a non null reason");
  }

  //change state
  this.state = state;
  this.value = value;
  return this.state;
 }
};

Now we're on to the fun stuff.

Then


This is where the usefulness of the promise comes in. The method handles all it's chaining and is the way we add new functions on to the list. First up let's get a basic then function that will check if the fulfilled and rejected are functions and then store them in an array. This is important as 3.2.4 says that it must return before invoking the functions so we need to store them somewhere to execute later. Also we need to return a promise so let's create the promise and store that with the functions in an array:

then: function( onFulfilled, onRejected ) {

 // initialize array
 this.cache = this.cache || [];

 var promise = Object.create(Aplus);

 this.cache.push({
  fulfill: onFulfilled,
  reject: onRejected,
  promise: promise
 });

 return promise;
}

Resolving

Next let's concentrate on what happens when we actually resolve the promise. Let's again try and take the simple case and we'll add on the other logic as we go. First off we either run the onFulfilled or onRejected based on the promise state and we must do this in order. We then change the status of their associated promise based on the return values. We also need to pass in the value (or reason) that we got when the state changed. Here is a first pass:

resolve: function() {
 // check if pending
 if ( this.state == State.PENDING ) {
  return false;
 }

 // for each 'then'
 while ( this.cache && this.cache.length ) {
  var obj = this.cache.shift();

  // get the function based on state
  var fn = this.state == State.FULFILLED ? obj.fulfill : obj.reject;
  if ( typeof fn != 'function' ) {
   fn = function() {};
  }

  // fulfill promise with value or reject with error
  try {
   obj.promise.changeState( State.FULFILLED, fn(this.value) );
  } catch (error) {
   obj.promise.changeState( State.REJECTED, error );
  }
 }
}


This is a good first pass. It handles the base case for normal functions. The two other cases we need to handle though are when we're missing a function (at the moment we're using a blank function but we really need to pass along the value or the reason with the correct state) and when they return a promise. Let's first tackle the problem of passing along an error or value when we're missing a function:


resolve: function() {
 // check if pending
 if ( this.state == State.PENDING ) {
  return false;
 }

 // for each 'then'
 while ( this.cache && this.cache.length ) {
  var obj = this.cache.shift();

  var fn = this.state == State.FULFILLED ? obj.fulfill : obj.reject;


  if ( typeof fn != 'function' ) {

   obj.promise.changeState( this.state, this.value );

  } else {

   // fulfill promise with value or reject with error
   try {
    obj.promise.changeState( State.FULFILLED, fn(this.value) );
   } catch (error) {
    obj.promise.changeState( State.REJECTED, error );
   }

  }

 }
}

If the function doesn't exist we're essentially passing along the state and the value. One thing that hit me when reading through this is that if you are using a onRejected function and you want to pass along the error state to the next promise is you'll have to throw another error, otherwise the promise will resolve with the returned value. I guess that this is a good thing as you can essentially use onRejected to "fix" errors by doing things like returning a default value.

There is only one thing left in resolving and that's to handle what happens when a promise is returned. The spec gives an example of how to do this at: http://promises-aplus.github.io/promises-spec/#point-65 so let's put it in

resolve: function() {
 // check if pending
 if ( this.state == State.PENDING ) {
  return false;
 }

 // for each 'then'
 while ( this.cache && this.cache.length ) {
  var obj = this.cache.shift();

  var fn = this.state == State.FULFILLED ? obj.fulfill : obj.reject;


  if ( typeof fn != 'function' ) {

   obj.promise.changeState( this.state, this.value );

  } else {

   // fulfill promise with value or reject with error
   try {

    var value = fn( this.value );

    // deal with promise returned
    if ( value && typeof value.then == 'function' ) {

     value.then( function( value ) {
      obj.promise.changeState( State.FULFILLED, value );
     }, function( reason ) {
      obj.promise.changeState( State.REJECTED, error );
     });
    // deal with other value returned
    } else {
     obj.promise.changeState( State.FULFILLED, value );
    }
   // deal with error thrown
   } catch (error) {
    obj.promise.changeState( State.REJECTED, error );
   }
  }
 }
}

Asynchronous

So far so good, but there are two bits we haven't dealt with. The first is that the onFulfilled and onRejected functions should not be called in the same turn of the event loop. To fix this we should only add our "then" functions to the array after the event loop. We can do this through things like setTimeout or process.nextTick. To make this easier we'll put on a method that will run a given function asynchronously so it can be overridden with whatever implementation you use. For now though we'll use setTimeout though you can use nextTick or requestAnimationFrame

async: function(fn) {
 setTimeout(fn, 5);
}

The last step is putting in when to resolve. There should be two cases when we need to check, the first is when we add in the 'then' functions as the state might already be set. This gives us a then method looking like:


then: function( onFulfilled, onRejected ) {

 // initialize array
 this.cache = this.cache || [];

 var promise = Object.create(Aplus);
 var that = this;
 this.async( function() {
  that.cache.push({
   fulfill: onFulfilled,
   reject: onRejected,
   promise: promise
  });
  that.resolve();
 });

 return promise;
}

and the second should be when the state is changed so add a this.resolve() to the end of the changeState function. Wrap it all in a function that will use Object.create to get you a promise and the final code will look like this:

Final


var Aplus = function() {

 var State = {
  PENDING: 0,
  FULFILLED: 1,
  REJECTED: 2
 };

 var Aplus = {
  state: State.PENDING,
  changeState: function( state, value ) {

   // catch changing to same state (perhaps trying to change the value)
   if ( this.state == state ) {
    throw new Error("can't transition to same state: " + state);
   }

   // trying to change out of fulfilled or rejected
   if ( this.state == State.FULFILLED ||
     this.state == State.REJECTED ) {
    throw new Error("can't transition from current state: " + state);
   }

   // if second argument isn't given at all (passing undefined allowed)
   if ( state == State.FULFILLED &&
     arguments.length < 2 ) {
    throw new Error("transition to fulfilled must have a non null value");
   }

   // if a null reason is passed in
   if ( state == State.REJECTED &&
     value == null ) {
    throw new Error("transition to rejected must have a non null reason");
   }

   //change state
   this.state = state;
   this.value = value;
   this.resolve();
   return this.state;
  },
  fulfill: function( value ) {
   this.changeState( State.FULFILLED, value );
  },
  reject: function( reason ) {
   this.changeState( State.REJECTED, reason );
  },
  then: function( onFulfilled, onRejected ) {

   // initialize array
   this.cache = this.cache || [];

   var promise = Object.create(Aplus);

   var that = this;

   this.async( function() {
    that.cache.push({
     fulfill: onFulfilled,
     reject: onRejected,
     promise: promise
    });
    that.resolve();
   });

   return promise;
  },
  resolve: function() {
   // check if pending
   if ( this.state == State.PENDING ) {
    return false;
   }

   // for each 'then'
   while ( this.cache && this.cache.length ) {
    var obj = this.cache.shift();

    var fn = this.state == State.FULFILLED ?
     obj.fulfill :
     obj.reject;


    if ( typeof fn != 'function' ) {

     obj.promise.changeState( this.state, this.value );

    } else {

     // fulfill promise with value or reject with error
     try {

      var value = fn( this.value );

      // deal with promise returned
      if ( value && typeof value.then == 'function' ) {
       value.then( function( value ) {
        obj.promise.changeState( State.FULFILLED, value );
       }, function( error ) {
        obj.promise.changeState( State.REJECTED, error );
       });
      // deal with other value returned
      } else {
       obj.promise.changeState( State.FULFILLED, value );
      }
     // deal with error thrown
     } catch (error) {
      obj.promise.changeState( State.REJECTED, error );
     }
    }
   }
  },
  async: function(fn) {
   setTimeout(fn, 5);
  }
 };

 return Object.create(Aplus);

};

you might have noticed I also put in functions "fulfill" and "reject". The spec doesn't say anything about how to manually change the state of a promise. Other names may be used like "fail", "resolve" or "done" but I'm using "fulfill" and "reject" to keep in line with the specs and what they call their two functions.

Next time

In future I'll write a bit more about some patterns you can use promises for, like passing around data, making requests in parallel and caching. Promises are really powerful but they also come at a cost so I'll outline all the pros and cons and what their alternatives are in different situations, but for now hopefully this sheds some light on the internals of how a promise works.

*edit* Looks like the tests https://github.com/promises-aplus/promises-tests don't like errors thrown so I've changed the changeState to instead return the errors, not throw and the tests allow null reasons for errors so I've changed that and uploaded to github

12 comments:

  1. Good read, appreciate you taking the time to write this!!

    On the resolve object snippet on line 30 there is a typo:

    }, function( reason ) {
    obj.promise.changeState( State.REJECTED, error );
    })

    should be:

    }, function( error ) {
    obj.promise.changeState( State.REJECTED, error );
    })

    it is corrected on the final code example.


    Thanks,

    ReplyDelete
  2. I love the "understand by doing" way of learning. I have to admit I didn't fully grok promises until I read your post. Thanks a ton!

    ReplyDelete
  3. Wont this give an error on first resolve call. Since we default state to PENDING and we check state return false in resolve method call?

    ReplyDelete
    Replies
    1. The naming might be throwing you a bit off. Resolve is more of an internal method that gets called by the promise and is meant to run the then functions only when a promise is fulfilled. changeState is the actual function you would call to fulfill or reject a promise

      Delete
  4. Can I have some examples of how to use it? Thanks.

    ReplyDelete
    Replies
    1. Sure thing - check out my post here http://modernjavascript.blogspot.ca/2013/09/promise-patterns.html

      Delete
  5. Can you add your Promises A+ adapter test file to the git repository so I can see how one might check the compliance tests?

    ReplyDelete
    Replies
    1. I'll see if I can get around to it over thanksgiving. It shouldn't be too hard to create, to resolve you just need to call the 'changeState' function with the needed arguments

      Delete
    2. Since I was testing my own promise implementation and got lots of errors from the test suite, I thought it might be a good idea to have a positive sample running the suite against yours.

      However, either my adapters are somehow crappy, or it does simply not pass: 390 passing (2m), 482 failing

      The first error is:
      1) 2.2.2: If `onFulfilled` is a function, 2.2.2.2: it must not be called before `promise`
      is fulfilled fulfilled after a delay:
      Error: timeout of 200ms exceeded

      Or see number 3, which seems really not to be handled in your code:
      3) 2.3.1: If `promise` and `x` refer to the same object, reject `promise` with a
      `TypeError' as the reason. via return from a fulfilled promise:
      Error: timeout of 200ms exceeded

      The only thing I really struggle with is that timeout as error information in any case...

      Cheers, Roland

      Delete
    3. Promises/A+ has since gone to version 1.1. I wrote this blog post when it was at version 1 and has had a lot of breaking changes especially in the resolution.

      Now for the good news. I have just created a v1.1 spec compliant implementation and am giving a talk about it at Codemash on friday. Afterwards I'll be posting the slides and a new blog post - I put up the code for you here: https://github.com/rhysbrettbowen/promise_impl

      Delete
  6. I think you're assuming that onReject will always return an Error object. Am I missing something, or will this implementation break if onReject returns a string?

    ReplyDelete
    Replies
    1. The part of the spec that deals with returns from the provided functions are here: http://promises-aplus.github.io/promises-spec/#point-47

      Basically if either function trhows an error then send an error to the next promise. If the function to be run is not a function (i.e. error but no reject function given) then pass on that state/value. And finally if a function returns a value then you need to run the resolve on the next promise with that value.

      What this means is that if you return a value from a reject, be it an error or a string then it will resolve any 'thened' functions as fulfilled rather than error. To pass along an error state you would have to re-throw the error.

      This can be handy as you can have a chain which errors in the middle, but you can use that reject function to correct or use default data and go back in to the success path.

      Delete