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 nowfulfill
, 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));
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\$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\$