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.
- There are no more callbacks to resolve, in which case, just return.
ret
is a promise.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.