Easy asynchrony with ES6

comment

Promises and generators can be used together to describe complex asynchronous flow in a simple, synchronous way.

Chrome supports both native Promises (version 33) and ES6 generators but it's necessary to enable Experimental JavaScript at chrome://flags/#enable-javascript-harmony (version 36).

In Firefox both promises and generators are supported by default (version 29).

The function below converts a generator into a promise resolving state machine. In this generator each yield works as C♯'s await.

function async(generatorFunction) {
    return function(/*...args*/) {
        var generator = generatorFunction.apply(this, arguments);
        return new Promise(function(resolve, reject) {
            function resume(method, value) {
                try {
                    var result = generator[method](value);
                    if (result.done) {
                        resolve(result.value);
                    } else {
                        result.value.then(resumeNext, resumeThrow);
                    }
                } catch (e) {
                    reject(e);
                }
            }
            var resumeNext = resume.bind(null, 'next');
            var resumeThrow = resume.bind(null, 'throw');
            resumeNext();
        });
    };
}

These two helper functions will convert DOM events and timeouts into promises that resolve when an event occurs or a specified amount of time passes.

function event(object, name) {
    return new Promise(function(resolve) {
        object.addEventListener(name, resolve);
    });
}

function wait(millis) {
    return new Promise(function(resolve) {
        setTimeout(resolve, millis);
    });
}

The run function will print the status of a given promise when it is fulfilled.

function run(promise) {
    promise.then(
        function(value) {
            print('Promise fulfilled with ' + value);
        },
        function(error) {
            print('Promise rejected with ' + error);
        }
    );
}

The proc generator below illustrates a simple asynchronous flow.

To run the example your browser has to support:

var proc = async(function* (id) {
    try {
        print('Start: ' + id);
        yield wait(1000);
        print('Press Continue executing 3 times.');

        continueBtn.disabled = false;
        var e;
        for (var i = 1; i <= 3; i++) {
            e = yield event(continueBtn, 'click');
            continueBtn.value = 'Continue executing (' + i + ')';
        }
        continueBtn.disabled = true;

        print('Event: ' + e);
        return 'End';
    } catch (err) {
        return 'Error ' + err;
    } finally {
      print('End of generator');
    }
});

run(proc(Math.random()));

The alternative version of the same code written using pure promises.

function procPromises(id) {
    return Promise.resolve(null)
        .then(function() { print('Start: ' + id); })
        .then(function() { return wait(1000); })
        .then(function() { print('Press Continue executing.'); })
        .then(function() {
            continueBtn.disabled = false;
            var i = 0;
            var inner = function(e) {
                continueBtn.value = 'Continue executing (' + i + ')';
                if (i > 2) {
                    return e;
                }
                return event(continueBtn, 'click').then(function(evt) {
                    i++;
                    return evt;
                }).then(inner);
            };
            return inner();
        })
        .then(function(e) {
            e.target.disabled = true;
            print('Event: ' + e);
            return 'ok';
        })
        .catch(function(err) { return 'Error ' + err; })
        .then(function(result) {
            print('End of promise');
            return result;
        });
}

run(procPromises(Math.random()));

Note that inside the generator it is possible to use loops (for/while) around asynchronous functions. The pure-promise based approach requires creating complex inner functions to maintain the iteration state.

Generators look like synchronous code and that makes them more readable than the nested callbacks.

Decoupling promises from generators

It is possible to abstract away the asynchronicity or synchronocity of the code and use one generator for both types of the control flow.

For example the Stream type has a readBytes method that returns an array of bytes using calls to the readByte function. Utilizing generators we can reuse the same algorithm for both the synchronous code that deals with in-memory arrays and the asynchronous one that uses Promises to read bytes from the network or files.

function Stream(source) {
    this.source = source;
}

Stream.prototype = {
    readBytes: function*(num) {
        var bytes = [];
        for (var i = 0; i < num; i++) {
            bytes.push(yield this.source.readByte());
        }
        return bytes;
    }
};

The synchronous ArraySource can be used as the source for the stream object. ArraySource uses throw and return statements to control the program flow:

function ArraySource(rawBytes, index) {
    this.rawBytes = rawBytes;
    this.index = index || 0;
}

ArraySource.prototype = {
    readByte: function() {
        if (!this.hasMoreBytes()) {
            throw new Error('Cannot read past the end of the array.');
        }
        return this.rawBytes[this.index++];
    },
    hasMoreBytes: function() {
        return this.index < this.rawBytes.length;
    }
};

Reading bytes from this source using the Stream object can also be synchronous:

var syncSource = new ArraySource([1, 2, 3, 4, 5]);

// reading a byte from the synchronous source
print(syncSource.readByte());

var stream = new Stream(syncSource);

try {
    // reading bytes array synchronously
    var bytes = sync(stream.readBytes(4));
    print(bytes);
} catch (e) {
    print(e);
}

    The second option is the FileSourceMock object that uses Promises to return bytes or report errors:

    function FileSourceMock(rawBytes, index) {
        this.rawBytes = rawBytes;
        this.index = index || 0;
    }
    
    FileSourceMock.prototype = {
        readByte: function() {
            if (!this.hasMoreBytes()) {
                var err = Error('Cannot read past the end of the array.');
                return Promise.reject(err);
            }
            return Promise.resolve(this.rawBytes[this.index++]);
        },
        hasMoreBytes: function() {
            return this.index < this.rawBytes.length;
        }
    };

    Reading from this source requires using the asynchronous control flow but the readBytes method does not need to be modified:

    var asyncSource = new FileSourceMock([1, 2, 3, 4, 5]);
    
    // reading a byte from the asynchronous source
    asyncSource.readByte().then(function (byte) {
        print(byte);
    });
    
    var stream = new Stream(asyncSource);
    
    // reading byte array asynchronously
    async(stream.readBytes(4)).then(function (bytes) {
        print(bytes);
    }, function(error) {
        print(error);
    });

      The code above uses two strategies of executing the generator — asynchronous and synchronous.

      The asynchronous flow control uses Promises to represent success and failure and the then method to continue executing generator when the promise is fulfilled.

      var async = flow({
          resume: function(value, next, abort) {
              return Promise.resolve(value).then(next, abort);
          },
          finish: function(value) {
              return Promise.resolve(value);
          },
          abort: function(error) {
              return Promise.reject(error);
          }
      });

      The synchronous flow uses the return and throw statements to report the success or the failure. The next function which represents the flow continuation is executed synchronously passing the value returned by the previous step.

      var sync = flow({
          resume: function(value, next, abort) {
              return next(value);
          },
          finish: function(value) {
              return value;
          },
          abort: function(error) {
              throw error;
          }
      });

      The flow function separates the generator from the type of the control flow.

      function flow(controller) {
          return function wrap(generator) {
              function resume(method, previous) {
                  var result, value;
                  try {
                      result = generator[method](previous);
                  } catch (e) {
                      return controller.abort(e);
                  }
                  value = result.value;
                  // wrap inner generators in the same control flow
                  if (value && typeof value.next === 'function') {
                      value = wrap(value);
                  }
                  if (result.done) {
                      return controller.finish(value);
                  } else {
                      return controller.resume(value, next, abort);
                  }
              }
              var next = resume.bind(null, 'next');
              var abort = resume.bind(null, 'throw');
              return next();
          };
      }

      For a real-world use of this technique see the Low-level Bitcoin article.

      Comments

      Jonathan LeBlanc (@jcleblanc)
      Easy JavaScript concurrency with generators and promises.
      cancel

      Revisions

      1. Initial version.
      2. Split long code fragments into separate sections; change title to Easy asynchrony.
      3. Use async function wrapper instead of start.
      4. Add Decoupling promises from generators section.