12
\$\begingroup\$

Background

I was writing a promises/a+ implementation, just for fun. About halfway through I decided it wasn't that fun after all, so I decided to do something a little different.

Thinking of promises as a pattern (and not a contract), I figured they basically amount to the following (if I'm missing the point, please correct me):

  • They "flatten" async* code, avoiding callback hell.

  • They allow sensible error handling on async code.

  • They are composable, and can be chained.

  • If one promise resolves to another, that new promise must also resolve before the rest of the chain resolves (e.g. a then function returning a promise).

* I get that promises themselves don't care about async, I'm thinking more about what people use them for.

Design goals

Instead of trying to conform to the promises/a+ spec, I decided to try to create something that would allow for using a similar pattern without worrying about the promise contract. I thought it would be interesting to learn:

  • Can something as powerful as promises can be implemented in a simpler / more minimal way?

  • What improvements can be made on a promise-like pattern?

  • Is it reasonable to try to integrate promises with other promise-like things?

The first two points basically boil down to ditching the design contract idea (having a special interface for everything that returns a promise) and making a system that works better with the more common function-takes-a-callback style interfaces.

The third point is handled by having "thenable" entities that work with Promise.resolve, and by recognizing and resolving other "thenables" passed from one entity to another.

Promise-like entities

The Pledge API is similar to the Promises API.

Static methods

Use this instead of invoking the constructor.

  • Pledge.resolve(callback)

    Create a new pledge, return it, and resolve it after the current script finishes.

Instance methods

Use these on objects returned from Pledge.create and Pledge.resolve.

  • pledge.and(callback)

    Create a new pledge, set it as the next pledge to resolve after this instance, and return it.

    Similar to promise.then.

  • pledge.or(callback)

    Create a new pledge, set it as the error handler for this instance, and return this instance.

    Similar to promise.catch.

  • pledge.then(resolve, reject)

    This is not meant to be used directly. It's here so pledges can be converted to promises with Promise.resolve.

Callbacks

The pledge.or callback takes a single argument, an Error instance. All other callbacks behave as follows:

Callbacks take a variable number of arguments. The first argument, go, is a function to call to pass execution to the next pledge in the chain. The go function may be called with any number of arguments, which will be passed to the next pledge in the chain (similar to returning a value from promise.then). Any callback arguments after the first are passed in from the previous pledge in this manner.

If any arguments passed to go are Pledge instances, they will be resolved before the next pledge in the chain is resolved.

If any arguments passed to go are "thenable," they will be wrapped in pledges and resolved before the next pledge in the chain is resolved. This should allow pledges to integrate seamlessly with interfaces that return promises.

As a syntactic sugar, a callback may return a truthy value rather than calling go. This is equivalent to calling go with that value as the sole argument.

Experiments

There are a few experimental features that aren't really the core focus of the library, but might stick. Most notably, if you pass and multiple callback arguments, the next item in the chain will only resolve when they are all resolved. When using multiple callbacks, the next item in the chain will receive go plus one argument for each callback, containing a list of all arguments passed to go from that callback.

Examples

The go function is useful because it can pass multiple values to the next entity in the chain, while return can only pass a single value. It's also useful for using callback-style interfaces without much hassle. For example:

Pledge.resolve(function (go) {

    go("whatever.jpg");

}).and(function (go, src) {

    var image = new Image();

    image.onload = go;
    image.src = src;

}).and(function (go, event) {

    // the event argument was passed in from image.onload, do something with it here.

})

With promises, this kind of thing gets a little more ugly. You end up wanting to use a promise-aware image loader. This is where promises work as a design contract... if you have a special promise interface for everything, it works well. If you just want to do ad-hoc stuff without using special interfaces, you have to do something awkward like this:

new Promise(function (resolve, reject) {

    resolve("whatever.jpg");

}).then(function (src) {

    return new Promise(function (resolve, reject) {

        var image = new Image();

        image.onload = resolve;
        image.src = src;

    });

}).then(function (event) {

    // do something with the event argument.

})

However, this only works because onload passes the callback a single argument, as resolve only accepts a single argument. To use promises with something that passes the callback multiple arguments, you'd have to do even more work:

new Promise(function (resolve, reject) {

    resolve("whatever.txt");

}).then(function (src) {

    return new Promise(function (resolve, reject) {

        loadFile(src, function (data, filesize, lastModified) {
            resolve({ 
                data: data, 
                filesize: filesize,
                lastModified: lastModified
            });
        });

    });

}).then(function (fileInfo) {

    // do something with the file info.

})

With Pledge, we could write this instead:

Pledge.resolve(function (go) {

    go("whatever.txt");

}).and(function (go, src) {

    loadFile(src, go);

}).and(function (go, data, filesize, lastModified) {

    // do something with the file info.

})

There is no need for a special file loader that returns pledges; we can use pledges with existing callback mechanisms easily.

Implementation

It's sort of rough in some parts as I'm still developing the idea, but here it is. I'm more interested in having the overall design of the library reviewed than the code itself, but comments on the code are of course welcome.

Note: go is now fulfill, and callbacks are "vows".

/** Fulfill function.

    Passed in as the first argument to a vow function.
    Accepts any number of arguments to pass to the next pledge.

    @typedef function(...)
*/
Function.Fulfill;

/** Vow function.

    Passed into vow list functions. Accepts a fulfill function
    as the first argument, followed by any arguments passed from
    the previous pledge.

    @typedef function(Function.Fulfill, ...)
*/
Function.Vow;

/** Vow list function.

    A function that accepts a variable length list of vow functions.

    @typedef function(...[Function.Vow]) 
*/
Function.VowList;

( /** @type function(Window) **/ function(global) {

    /** Pledge constructor.

        Don't invoke this, use Pledge.create or Pledge.resolve instead
        (for consistency; CJS-style exports don't expose this).

        @constructor

        @param {...Function.Vow} vows
    */
    function Pledge(vows) {

        /** Vows.

            When resolving, a pledge will resolve each vow before
            resolving the next pledge in the chain.

            @type Arguments.<Function.Vow> 
        */
        this.vows = arguments;
        /** Pending.

            An array of functions to execute once this pledge is fulfilled.

            @type Array.<function()>
        */
        this.pending = [];
        /** Resolution.

            While resolving, a pledge will populate this array with
            the arguments lists passed to each vow's fulfill function.

            @type {Error|Array.<Arguments>}
        */
        this.resolution = [];
        /** Resolved count.

            The number of vows resolved so far.

            @type number
        */
        this.resolvedCount = 0;
        /** Fail.

            A pledge to resolve when an error is thrown.

            @type Pledge
        */
        this.fail;
        /** Locked.

            As soon as a pledge starts resolving, it is locked.
            If it was already locked, it will throw an error.

            @type boolean
        */
        this.locked;
        /** Ready.

            A pledge with all vows fulfilled is ready.

            @type boolean
        */
        this.ready;
    }

    //
    // Public API. 
    //

    /** Pledge.resolve

        Create a new pledge, return it, and resolve it
        after the current script finishes, if it's not
        locked by then.

        @type Function.VowList: Pledge
    */
    Pledge['resolve'] = function() {
        var /** @type Pledge */ pledge = create(arguments);

        setTimeout(function() { resolve(pledge); }, 0);

        return pledge;
    };

    /** pledge.and

        Appends a new pledge to this one and returns it.

        @type Function.VowList: Pledge
    */
    Pledge.prototype['and'] = function() {

        return resolveNext(this, create(arguments));
    };

    /** pledge.or

        Sets a new pledge as this pledge's error handler,
        and returns this pledge.

        @type Function.VowList: Pledge
    */
    Pledge.prototype['or'] = function() {
        if (this.locked) {
            throw new Error("locked");
        }
        this.fail = create(arguments);

        return /** @type Pledge */ this;
    };

    /** pledge.then

        Allows promises to resolve pledges.
        Not meant to be called from user code.
    */
    Pledge.prototype['then'] = function(fulfill, reject) {
        var result = this;

        function thenFulfill(go, value) { fulfill(value); }
        function thenReject(error) { reject(error); }

        if (fulfill) {
            result = result['and'](thenFulfill);
        }
        if (reject) {
            result = result['or'](thenReject);
        }

        return result;
    };

    //
    // Internal stuff. 
    //

    /** @type function(this:Array, number): Array */
    var arraySlice = Array.prototype.slice;
    /** @type function(Array=): Array */
    function arrayCopy(list) { return arraySlice.call(list || [], 0); }

    /** Create pledge from vow list.

        @param {Arguments.<Function.Vow>} vows
        @return {Pledge}
    */
    function create(vows) {
        var pledge = new Pledge();

        pledge.vows = vows;

        return pledge;
    }

    /** Argument value.

        Get a pledge's resolution in a format suitable for
        an argument passed to a vow function.

        @param {Pledge} self
        @return {Object}
    */
    function argValue(self) {
        var value = self.resolution;

        if (value instanceof Error) {
            return value;
        }
        if (value.length == 1) {
            value = value[0];
            if (value.length == 1) {
                value = value[0];
            }
        }

        return value;
    }

    /** List value.

        Get a pledge's resolution in a format suitable for
        a list of arguments passed to a vow function.

        @param {Pledge} self
        @return {Array|Error}
    */
    function listValue(self) {
        var value = self.resolution;

        if (value instanceof Error) {
            return value;
        }
        value = arrayCopy(value)
        if (value.length == 1) {
            return value[0];
        }
        for (var i = value.length; i--;) {
            if (value[i].length == 1) {
                value[i] = value[i][0];
            }
        }

        return value;
    }

    /** Vow count.

        @param {Pledge} self
        @return {number}
    */
    function vowCount(self) {

        return self.vows && self.vows.length || 0;
    }

    /** Set ready state to true and run anything in pending queue.

        @param {Pledge} self 
    */
    function setReady(self) {
        var completion;

        self.ready = true;
        while ((completion = self.pending.pop())) {
            completion();
        }
    }

    /** Resolve arguments.

        Resolve all pledges passed to a fulfill function.

        @param {Pledge} self 
        @param {Arguments} args
    */
    function resolveArgs(self, args) {

        var argIndex = args.length,
            pledgeCount = 0, 
            pledgesResolved = 0,
            arg;

        function ready() {
            if (++self.resolvedCount == vowCount(self)) {
                setReady(self);
            }
        }

        /** 
            @param {Error} error 
        */
        function handleError(error) {
            self.resolution = error;
            setReady(self);
        }

        /** 
            @param {number} argIndex 
            @param {Pledge} pledge
        */
        function finalize(argIndex, pledge) {
            ++pledgeCount;
            // while (pledge.next) { pledge = pledge.next; }
            pledge['and'](function(){
                args[argIndex] = argValue(pledge);
                if (++pledgesResolved == pledgeCount) {
                    ready();
                }
            })['or'](handleError);
            // while (pledge.previous) { pledge = pledge.previous; }
            // resolve(pledge);
        }

        // resolve any pledges and thenables passed to fulfill.
        for (argIndex = args.length; argIndex--;) {
            arg = args[argIndex];
            // if it's a pledge, finalize it.
            if (arg instanceof Pledge) {
                finalize(argIndex, arg);
            // if it's thenable, wrap it in a pledge and finalize it.
            } else if (arg && arg['then'] && arg['then'].call) {
                finalize(argIndex, Pledge['resolve'](function(fulfill) {
                    arg['then'](fulfill, handleError);
                }));
            }
            // otherwise it's an immediate value.
        }
        if (!pledgeCount) {
            ready();
        }
    }

    /** Resolve vow.

        @param {Pledge} self 
        @param {number} vowIndex 
        @param {Array|Error=} args 
    */
    function resolveVow(self, vowIndex, args) {

        var /** @type Function.Vow */ vow = self.vows[vowIndex],
            /** @type Object */ result, 
            /** @type Pledge */ pledge,
            /** @type Array */ newArgs;

        /** @type Function.Fulfill */
        function fulfill() {
            self.resolution[vowIndex] = arguments;
            resolveArgs(self, arguments);
        }

        if (args instanceof Error) {
            args.rethrow = true;
            newArgs = [args];
        } else {
            newArgs = arrayCopy(args);
            newArgs.unshift(fulfill);
        }
        try {
            result = vow.apply(self, newArgs);
        } catch(error) {
            if (error.rethrow) {
                throw error;
            }
            self.resolution = error;
            setReady(self);
        }
        if (result) {
            fulfill(result);
        }
    }

    /** Resolve another pledge after this one.

        @param {Pledge} self
        @param {Pledge} next
    */
    function resolveNext(self, next) {
        function ready() { resolve(next, listValue(self)); }

        if (self.ready) {
            ready();
        } else {
            self.pending.push(ready);
        }

        return next;
    }

    /** Resolve pledge.

        @param {Pledge} self
        @param {Array|Error=} args
        @param {boolean=} failure
    */
    function resolve(self, args, failure) {
        var len = vowCount(self);        
        if (args instanceof Error) {
            if (failure) {
                resolveVow(self, 0, args);
                return;
            }
            self.resolution = args;
            self.locked = true;
            if (self.fail) {
                resolve(self.fail, args, true);
            }
            return;
        }

        if (self.locked) {
            throw new Error("locked");
        }
        self.locked = true;
        for (var i = 0;  i < len; i++) {
            resolveVow(self, i, args);
        }
    }

    //
    // Exports.
    //

    if (global['define']) {
        global['define'](Pledge);
    } else if (global['exports']) {
        global['exports']['create'] = Pledge['create'];
        global['exports']['resolve'] = Pledge['resolve'];
    } else {
        global['Pledge'] = Pledge;
    }

}(this));
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Why Pledge['create'] vs. Pledge.create? Since they are method names, they must be valid JS identifiers and thus don't need to be quoted. Personal style? \$\endgroup\$ May 25, 2014 at 19:58
  • 1
    \$\begingroup\$ @DavidHarkness closure compiler with advanced optimizations will preserve the name create when you write it like that. Those no-op statements in the ctor are there for closure too, going to annotate them later. \$\endgroup\$
    – Dagg
    May 25, 2014 at 23:34

1 Answer 1

3
\$\begingroup\$

Very interesting question.

From a high level perspective, the code gets a bit hairy from Internal stuff on, which means the API is quite clear.

I really only have nitpickings;

  • This code could have been shorter if the Pledge constructor could take the vows parameter:

    function create(vows) {
        var pledge = new Pledge();
    
        pledge.vows = vows;
    
        return pledge;
    }
    

    could be

    function create(vows) {
        return new Pledge( vows );
    }
    
  • This: in a format suitable for an argument passed to a vow function. and this if (value.length == 1) { value = value[0]; drive me nuts. I dont understand from the code why you need this, and the comments are not helping me understand this either. You have this array shifting in both argValue and listValue, you could have a common helper function here

  • I got stuck on this:

    return self.vows && self.vows.length || 0;
    

    consider writing this for readability

    return ( self.vows && self.vows.length ) || 0;
    

    especially since you use compiler afterwards

  • In setReady(self), the naming could be better, if self is a pledge, why not call the parameter pledge ? Since a pledge has vows why not call completion -> vow. Compare and contrast

    function setReady(self) {
        var completion;
    
        self.ready = true;
        while ((completion = self.pending.pop())) {
            completion();
        }
    }
    

    and

    function setReady(pledge) {
        var vow;
    
        pledge.ready = true;
        while ((vow = pledge.pending.pop())) {
            vow();
        }
    }
    
  • In resolveArgs as well, I would replace self with pledge, things become far more clearer.

All in all, I like the code, it is more readable than other Promise libraries that I tried to grok.

\$\endgroup\$
4
  • \$\begingroup\$ Thanks for the comments. Your first point is a good one one, I should probably do that. Originally I wanted to make Pledge take a variable-length list of arguments, each being a vow function (which is how it works now). Later I decided Pledge shouldn't be called directly as it made CJS-style exports difficult, so I should probably change the API to suit internal use. \$\endgroup\$
    – Dagg
    Jun 30, 2014 at 14:58
  • \$\begingroup\$ For the second point, the reason is kind of touched on in the "experiments" section. I purposely didn't document this very explicitly because I'm not sure it's going to stay like that, but the idea is that a pledge containing multiple vows will get passed to the next pledge as a list of results, while a pledge containing a single vow will just get passed as the result of that vow, not a list. Similarly, a vow with multiple fulfillment values gets passed as a list, and a vow with a single fulfillment value will just get passed as that value. \$\endgroup\$
    – Dagg
    Jun 30, 2014 at 15:02
  • \$\begingroup\$ The third thing you mention, I feel like this is a fairly common thing to do in JS, and I didn't think it would be that confusing to someone coming from another language since I thought JS had similar operator precedence to most other languages for && and ||. I could be wrong about the precedence in other languages, though. Possibly having dealt with Lua a lot lately makes this pattern seem fairly normal to me. \$\endgroup\$
    – Dagg
    Jun 30, 2014 at 15:07
  • \$\begingroup\$ The fourth and fifth things are also holdovers from a common Lua style. The functions taking self as the first argument started out as members of Pledge.prototype, but they weren't meant to be public, so I took them out of the prototype, changed invocations to pass in the old callee as the first argument, and changed all references to this to that first argument. It probably would make more sense to call it pledge; I may do that in the next version. \$\endgroup\$
    – Dagg
    Jun 30, 2014 at 15:12

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.