Deferrables

Imagine, if you will, some JavaScript models that look something like this:

function User (...) {
  // initialise user
}

User.fromId = function (id, cb) {
  // 'returns' an error, or a User
  db.find(..., cb);
}

User.prototype.getGroups = function (cb) {
  // 'returns' an error, or an array of Groups
  db.find(..., cb);
}

function Group (...) {
  // initialise group
}

Group.fromId = function (id, cb) {
  // 'returns' an error, or a Group
  db.find(..., cb);
}

Group.prototype.getOwner = function (cb) {
  // 'returns' an error, or a User
  db.find(..., cb);
}

Fairly standard stuff, and nothing new. If you now wanted to get a group’s owner’s other groups, you might tradionally end up with something like this:

Group.fromId(id, function (err, group) {
  if (err)
    return dealWithError(err);

  group.getOwner(function (err, user) {
    if (err)
      return dealWithError(err);

    user.getGroups(function (err, groups) {
      if (err)
        return dealWithError(err);

      // do something with the groups
    });
  })
});

It would be a lot nicer, and cleaner, if instead of all that callback spaghetti, we could instead write something more like this:

Group.fromId(id).getOwner().getGroups(function (err, groups) {
  if (err)
    return dealWithError(err);

  // do something with the groups
})

I was playing around with this concept today, to see if it was actually possible, and I came up with an idea 1 that makes it fairly straightforward. It’s definitely a work in progress, but it’s mostly behaving.

function User (...) {
  // initialise user
}

User.fromId = deferrable(function (id, cb) {
  // 'returns' an error, or a User
  db.find(..., cb);
}, User.prototype);

User.prototype.getGroups = function (cb) {
  // 'returns' an error, or an array of Groups
  db.find(..., cb);
}

function Group (...) {
  // initialise group
}

Group.fromId = deferrable(function (id, cb) {
  // 'returns' an error, or a Group
  db.find(..., cb);
}, Group.prototype);

Group.prototype.getOwner = deferrable(function (cb) {
  // 'returns' an error, or a User
  db.find(..., cb);
}, User.prototype);

Where we once had functions that returned a error/value via a callback, we’ve now ‘decorated’ them with a deferrable wrapper, which allows us to chain methods as if there was no delay. It ends up looking something like this:

function deferrable (fn, proto) {
  // number of arguments original function expects
  var len = fn.length;

  var wrapper = function () {
    var args = Array.prototype.slice.call(arguments);
    var proxy = null;

    // naive test to check if we're missing callback
    if (args.length === len - 1) {
      // generate proxy response
      proxy = new Deferred(proto);
      // add our own callback
      args.push(proxy._done);
    }

    // call original function with passed arguments
    fn.apply(this, args);

    // return our proxy
    return proxy;
  }
  wrapper.deferrable = proto;
  return wrapper;
}

There’s not much to this, in reality. It takes a function and a prototype for the response we’re expecting, and returns another function that checks to see if you’re passing in a callback or not 2. For the observant amongst you, it’s basically currying. The heavy lifting is done by a Deferred proxy object.

function Deferred (proto) {
  if (!(this instanceof Deferred))
    return new Deferred(proto);

  var deferred = this;
  var history = [];
  var done = false;
  var error = null;
  var binding = null;

  // loop through properties of prototype
  Object.getOwnPropertyNames(proto).forEach(function(key) {

    // only worried about object methods right now
    if (typeof proto[key] === 'function') {

      // mirror method onto proxy object
      Object.defineProperty(deferred, key, {
        __proto__: null,
        enumerable: true,
        value: function () {
          var args = Array.prototype.slice.call(arguments);

          // respond immediately if this proxy is bound
          if (done) {
            if (error) {
              var cb = args.pop();
              args.push(cb);
              if (typeof cb === 'function')
                cb.call(deferred, error);
            } else if (binding) {
              binding[key].apply(binding, arguments);
            }
            return;
          }

          var response = null;

          // check to see if the function is itself deferrable
          if (proto[key].deferrable) {
            response = new Deferred(proto[key].deferrable);
            args.push(response._done);
          }

          // keep track of calls made to this proxy object
          history.push({
            method: key,
            args: args
          });

          return response;
        }
      });
    }
  });

  // set up a callback function
  Object.defineProperty(deferred, '_done', {
    __proto__: null,
    value: function (err, rsp) {
      if (done)
        throw new Error('Deferred already complete');

      done = true;

      if (err) {
        error = err;
        history.forEach(function(item) {
          var cb = item.args.pop();
          item.args.push(cb);
          if (typeof cb === 'function')
            cb.call(deferred, err);
        });
      } else {
        binding = rsp;
        history.forEach(function(item) {
          rsp[item.method].apply(rsp, item.args);
        });
      }
    }
  });
}

This Deferred object acts as a proxy for the eventual response type, allowing you to access methods before they’re technically available. Once the callback has been made, the proxied function will be called. This technically works with any function, and it’s possible to defer callbacks that return things like arrays too:

...

User.prototype.getGroups = deferrable(function (cb) {
  db.find(..., cb); // 'returns' an error, or an array of Groups
}, Array.prototype);

...

Group.fromId(id).getOwner().getGroups().forEach(function(group) {
  // process group
}

Not quite sure what to do when an error is thrown further up the chain in scenarios like this though. Something to think about, anyway.

  1. I don’t know as I actually came up with the idea. It’s quite possible it’s been done before. But I didn’t bother to look. So I came up with it.
  2. This check is somewhat naive, unfortunately, and makes variable arguments impossible. In the course of writing this, I’ve been pondering whether it might not be better testing to see if the final argument is a function or not. For now, however, it works OK.

Comments

That's pretty great – a lot more readable and flexible than doing something like chain(fn, args).chain(fn, args), and doesn't look like there'd be much more overhead? Goes away to rewrite everything

Your email address will not be published. Required fields are marked *

HTML is not allowed (it'll appear as typed), but you can use Markdown instead. For the unitiated, paragraphs will be auto-inserted, but links won't be converted unless you [write them properly](http://example.com/).