pinkyPromise: JavaScript Promises in 45 Lines of Code

Disclaimer: these are not full-featured promises and don't follow any official promises spec. However, the concepts are the same, and it helps explain what is going on behind the scenes. This method is intended to be especially concise and relatively easy to understand (though still quite challenging).

What does it take to go from this:

// mock async function
function incrementAsync(n, cb) {  
    setTimeout(function() {
        cb(n + 1);
    }, 100);
}

var n0 = 1;

incrementAsync(n0, function(n1) {  
    incrementAsync(n1, function(n2) {
        incrementAsync(n2, function(n) {
            console.log(n); // => 4
        });
    });
});

To this?

// wrap incrementAsync() and return a promise
var incrementAsyncPromise = function(n) {  
    var promise = pinkyPromise();

    incrementAsync(n, function(ret) {
        promise.resolve(ret);
    });

    return promise;
};

var n0 = 1;

incrementAsyncPromise(n0)  
    .then(incrementAsyncPromise)
    .then(incrementAsyncPromise)
    .then(function(n) {
        console.log(n); // => 4
    });

Or, even better, to this?

var incrementAsyncPromise = pinkyPromise(incrementAsync);

var n0 = 1;

incrementAsyncPromise(n0)  
    .then(incrementAsyncPromise)
    .then(incrementAsyncPromise)
    .then(function(n) {
        console.log(n); // => 4
    });

45 Lines of Code

Step 1

We’ll need pinkyPromise() to return an object with two methods: .then and .resolve.

function pinkyPromise() {  
    return {
        then: function() {},
        resolve: function() {}
    };
}

Step 2

Every time .then is called, push the callback into a queue and return the same object.

function pinkyPromise() {  
    var queue = [];

    return {
        then: function(cb) {
            if (cb) {
                // add cb to queue
                queue.push(cb);
            }
            return this;
        },
        resolve: function() {}
    };
}

That's already 14 lines. Almost a third of the way there!

Step 3

Set up your resolve function to accept a single parameter: the value to return.

function pinkyPromise() {  
    var queue = [];

    function resolve(ret) {

    }

    return {
        then: function(cb) {
            if (cb) {
                // add cb to queue
                queue.push(cb);
            }
            return this;
        },
        resolve: resolve
    };
}

Step 4

There are three scenarios that could happen when .resolve is called.

  1. There are no more callbacks to resolve, in which case, just return.
  2. ret is a promise.
  3. ret is not a promise.
function pinkyPromise() {  
    var queue = [];

    function resolve(ret) {
        if (!queue.length) {
            return;
        } else if (ret && ret.then) {
            // ret is a promise
        } else {
            // ret is not a promise
        }
    }

    return {
        then: function(cb) {
            if (cb) {
                // add cb to queue
                queue.push(cb);
            }
            return this;
        },
        resolve: resolve
    };
}

24 lines! This is where it gets fun.

Step 5

If ret isn't a promise, then lets send it to the oldest callback and resolve the new return value.

function pinkyPromise() {  
    var queue = [];

    function resolve(ret) {
        if (!queue.length) {
            return;
        } else if (ret && ret.then) {
            // ret is a promise
        } else {
            // ret is not a promise
            var newRet = queue.shift()(ret);
            resolve(newRet);
        }
    }

    return {
        then: function(cb) {
            if (cb) {
                // add cb to queue
                queue.push(cb);
            }
            return this;
        },
        resolve: resolve
    };
}

Step 6

If ret is a promise, then we'll call its then method and resolve within the same scope!

function pinkyPromise() {  
    var queue = [];

    function resolve(ret) {
        if (!queue.length) {
            return;
        } else if (ret && ret.then) {
            // ret is a promise
            ret.then(resolve);
        } else {
            // ret is not a promise
            var newRet = queue.shift()(ret);
            resolve(newRet);
        }
    }

    return {
        then: function(cb) {
            if (cb) {
                // add cb to queue
                queue.push(cb);
            }
            return this;
        },
        resolve: resolve
    };
}

Step 7

The following is now possible:

var incrementAsyncPromise = function(n) {  
    var promise = pinkyPromise();

    incrementAsync(n, function(ret) {
        promise.resolve(ret);
    });

    return promise;
};

But I want to be able to simply pass in incrementAsync rather than explicitly wrap it. Since most async functions accept the callback as the final argument, we can abstract the above into a function called promisify. We'll allow a fn argument to be passed into pinkyPromise, and instead of returning the promise object, we'll return the promisified function:

function pinkyPromise(fn) {  
    var queue = [];

    function promisify(fn) {
        // converts fn w/ callback to return promise
        return function() {
            var promise = pinkyPromise();
            var args = Array.prototype.slice.call(arguments);

            // add promise.resolve as final argument
            args.push(promise.resolve);

            fn.apply(this, args);

            return promise;
        };
    }

    function resolve(ret) {
        if (!queue.length) {
            // no more callbacks
            return;
        } else if (ret && ret.then) {
            // ret is a promise
            ret.then(resolve);
        } else {
            // ret is not a promise
            var newRet = queue.shift()(ret);
            resolve(newRet);
        }
    }

    // if fn, return promisified fn
    // otherwise return a promise
    return fn ? promisify(fn) : {
        then: function(cb) {
            if (cb) {
                // add cb to queue
                queue.push(cb);
            }
            return this;
        },
        resolve: resolve
    };
}

That's it!

Congratulations! You've just solved Callback Hell™ with straight up functional programming prowess.

Check it out on GitHub.

If you're interested in a more thorough look at promises, Matt Greer's JavaScript Promises...In Wicked Detail was extremely helpful as I was beginning to wrap my head around them. He goes into much, much more detail, including rejecting promises. Highly recommended.

Also check out James Coglan's very insightful article Callbacks are imperative, promises are functional: Node’s biggest missed opportunity.