Asynchronous programming with ES6 generators, promises and npm-co

Asynchronous programming with ES6 generators, promises and npm-co

This article explains what an npm co module is, how it works and how it can help you to write much cleaner and simpler asynchronous code.

The problem

Writing asynchronous code could be very tricky, if you never did it before or didn't learn or develop good patterns to approach it. Asynchronous programming in javascript became popular with arrival of single page applications and node.js (where most of the operations happen asynchronously by default). Traditionally javascript was handling async operations using callbacks, and at least once every web developer faced a problem called callback hell or pyramid of doom.

Solutions

A simple solution is to keep your code shallow, though error handling wouldn't be that simple. Another one, very well established solution is to use promises. Using ES6 generators we can simplify code for promises and continue writing code in a synchronous, easy to follow, manner, while keeping it asynchronous. If you're not familiar with how promises or generators are working, please do a quick research before you continue reading.

co to the rescue

co is a wrapper around promises and generators which allows to simplify asynchronous code a lot. Take a look at the example which shows how an async code could be written in a sync manner using co:

co(function* getResults(){
//assume read and request perform asynchronous actions
var a = read('index.js', 'utf8');
var b = request('http://google.ca');
//when all operations will finish
//res will have contents of index.js at [0]
//and response body from google at [1]
var res = yield [a, b];
//...code which does something with res
}).catch(function (ex) {
//if an error was thrown from read or request functions
});

Here is what co will do for us:

  • Both read and request will execute in parallel on line 7 and when both of them finish - res array will hold results. It doesn't matter if request finishes before read - co will ensure that results are assigned to appropriate indexes.
  • On node.js you would have to check for errors in both callbacks for read and request functions, to ensure that your application does not exit in an unhandled state, but with co we can only worry about an end result and add a single error handler using .catch.
  • Code is rather short and easy to understand

So what exactly is happening? How does it work? To understand it better lets go over code of the main function in co and describe it. Open code in new window so that you can put it side by side.

Let`s modify example just a little, in order to show how various scenarios will work in co:

//example.js
co(function* getResults(){
//not really doing anything important,
//but will be useful to show how co works
try {
yield false;
} catch (ex) {
console.log(ex.message);
}
//assume read and request perform asynchronous actions
var a = read('index.js', 'utf8');
var b = request('http://google.ca');
//when all operations will finish
//res will have contents of index.js at [0]
//and response body from google at [1]
var res = yield [a, b];
//...code, which does something with res
}).catch(function (ex) {
//if an error was thrown from read or request functions
});

First co saves context reference, in case you will ever want to use a context inside read or request functions:

//co/index.js:co
var ctx = this;
//Context binding example
co.call(context, function* getResults() {/*'this' will point to context*/})

Then it checks, if gen is a function. If it is, then co needs to initialize a generator object by calling a function and it also ensure that this has a proper context. If gen is already a generator object, a statement will be skipped:

//co/index.js:co
if (typeof gen === 'function') gen = gen.call(this);

Then co returns a new Promise and runs onFulfilled function with no arguments. Inside that function it will try to execute a generator code until a first yield statement.

//co/index.js:onFulfilled
var ret;
try {
ret = gen.next(res); //res is undefined
}

In our case following code would execute inside getResults generator:

//example.js
try {
yield false;
}

If that code could throw an error - promise would be rejected with an error object and co would exit.

//co/index.js:onFulfilled
catch (e) {
return reject(e);
}

In our case generator returns an object with a value property containing yielded value and this object is assigned to ret. At that moment of time code would look like that:

//co/index.js:onFulfilled
var ret;
try {
ret = {value: false, done: false};
}

Then onFulfilled will call next(ret);. If done would be true, co would resolve a promise with the value and exit from next:

if (ret.done) return resolve(ret.value);

Otherwise co attempts to convert ret.value to a promise (we'll skip toPromise for now). .call is there to provide original context in case ret.value is another yieldable value:

//co/index.js:next
var value = toPromise.call(ctx, ret.value);

On next line co checks if value is not falsy and if it is a promise, and if so, it adds appropriate handlers for promise fulfilment or rejection and exits from next.

//co/index.js:next
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);

Notice how onFulfilled, onRejected and next functions have references to resolve and reject callbacks from a new Promise, which was created at the beginning. If one of these functions calls resolve or reject, then top most promise will be settled.

In our case ret.value in our case is false, so next calls onRejected handler, passes it a new TypeError and exits.

//co/index.js:next
return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "' + String(ret.value) + '"'));

onRejected is working quite similar to onFulfilled function with the following difference - instead of running a code until next yield statement, it tries to throw an error inside generator function at the place where it stopped before:

//co/index.js:onRejected
var ret;
try {
ret = gen.throw(err);
}

That means it will replace yield false with throw err and example would look like this:

//example.js
try {
throw err; //err is a TypeError
} catch (ex) {
console.log(ex.message);
}

That behaviour is completely normal for generators and is described on MDN (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators):

You can force a generator to throw an exception by calling its throw() method and passing the exception value it should throw. This exception will be thrown from the current suspended context of the generator, as if the yield that is currently suspended were instead a throw value statement.

At that moment execution flow will get back to example code and, because an error was handled with try-catch, it will be logged in console. Example will continue execution until it will either reach the end of generator or face a new yield:

//example.js
var a = read('index.js', 'utf8');
var b = request('http://google.ca');
var res = yield [a, b];

So result of calling gen.throw in onRejected returns an array with a and b:

//co/index.js:onRejected
var ret;
try {
ret = {value: [a, b], done: false};
}

Catch brunch is skipped but it would otherwise reject the promise. Finally, another next call happens: it would skip immediate resolve, convert array to a promise created with Promise.all (check out arrayToPromise function), which will be settled only when a and b promises are settled or one of them is rejected. Note how arrayToPromise is making sure that Promise.all argument is an array of promises:

//co/index.js:arrayToPromise
obj.map(toPromise, this)

toPromise will first check if value is not falsy and if it is, it returns a value:

//co/index.js:toPromise
if (!obj) return obj;

If value is a promise already, toPromise will return it:

//co/index.js:toPromise
if (isPromise(obj)) return obj;

If value is a generator or a generator function, toPromise will recursively call co, pass it a context and an obj and as a result we will get a new promise:

//co/index.js:toPromise
if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);

If obj is a function, toPromise will wrap it in a promise. First argument in a function is assumed to be an error object (node.js error handling pattern, so all node js callback APIs are covered):

//co/index.js:toPromise
if ('function' == typeof obj) return thunkToPromise.call(this, obj);

If obj is an array or an object with yieldable properties, co will recursively convert them to promises:

//co/index.js:toPromise
if (Array.isArray(obj)) return arrayToPromise.call(this, obj);
if (isObject(obj)) return objectToPromise.call(this, obj);

So co ensures that a and b are converted to promises, and when both promises are fulfilled - Promise.all will ensure that results are assigned to appropriate indexes in an array. If one of them is rejected, then .catch callback from the example will be able to handle an error (log it somewhere, or re-try once again). In either scenario a new Promise (one returned from co at the very beginning) will be settled, code will be executed asynchronously, and in the manner expected by developer, and will have all benefits of central place for error handling.

In a nutshell this is exactly what happens in co: generators and promises take care of asynchronous operations and error handling, while keeping your code clean and easy to follow. Similar logic will be hidden behind a native support of async programming with ES7 async-await. If you want to use co, generators and Promises in non ES6 environments, try out 6to5 transpiler (links below).

Thank you and please feel free to ask questions in the comments, follow us on facebook and twitter pages, or subscribe to my feed.

Links

Promises

Generators