Skip to content

Instantly share code, notes, and snippets.

@getify
Last active October 15, 2020 01:44
Show Gist options
  • Save getify/1173cac45d15fc4ff0a880f32fd598ab to your computer and use it in GitHub Desktop.
Save getify/1173cac45d15fc4ff0a880f32fd598ab to your computer and use it in GitHub Desktop.
BetterPromise: a strawman experiment in subclassing Promise and "fixing" a bunch of its awkward/bad parts

Some things that are "better" with this BetterPromise implementation:

  • BetterPromise # then(..) accepts a BetterPromise (or Promise) instance passed directly, instead of requiring a function to return it, so that the promise is linked into the chain.

    var p = BetterPromise.resolve(42);
    
    var q = Promise.resolve(10);
    
    p.then(console.log).then(q).then(console.log);
    // 42
    // 10
  • BetterPromise # unthen(..) / BetterPromise # uncatch(..) / BetterPromise # unfinally(..) allows you to unregister a fulfillment or rejection handler that you previously registered on a promise via then(..) or catch(..), or unregister a resolution handler that you previously registered on a promise via finally(..).

    NOTE: This seems to be the majority use-case for why many like/want "promise cancelation" -- IOW, often what you want is to just stop observing a promise's resolution, not actually forcibly cancel the operation is comes from.

    var p = new BetterPromise(function(res){
       setTimeout(function(){ res(42); },100);
    });
    
    function f1(v) { console.log(`f1: ${v}`); }
    function f2(v) { console.log(`f2: ${v}`); }
    function f3() { console.log("finally!"); }
    
    p.then(f1);
    p.then(f2);
    p.finally(f3);
    p.unthen(f1);
    p.unfinally(f3);
    // later
    // f2: 42
  • BetterPromise # finally(..) is included (assumed implemented since it's already stage-4). Registers a resolution handler which is called on either fulfillment or rejection, sorta like if you did then( fn, fn ) (but not exactly).

    var p = BetterPromise.resolve(42);
    p
       .finally(function(){ console.log("resolved!"); })
       .then(function(v){ console.log(`still: ${v}`); });
    // resolved!
    // still: 42
  • BetterPromise # thenLog() / BetterPromise # catchLog() inserts a step in a promise chain that simply prints the value to the console (console.log for fulfillment, console.error for rejection) and passes the value or rejection along to the next step untouched.

    var p = BetterPromise.resolve(42);
    p
      .thenLog()
      .then(function(v){ console.log(`still: ${v}`); });
    // 42
    // still: 42
  • Instead of silently being swallowed, if a synchronous exception is thrown in a BetterPromise constructor after the promise has already been synchronously resolved (fulfillment or rejection), that exception overrides and causes the promise to be rejected with that exception.

    var p = new BetterPromise(function(res){
       res(42);
       throw 10;
    });
    
    p.then(
       function(v){ console.log(`then: ${v}`); },
       function(e){ console.log(`oops: ${e}`); }
    );
    // oops: 10
  • BetterPromise.try(..) (static helper) is implemented (so not just draft). Runs a function (with no arguments) synchronously, returns a promise for its return value (or adopts its promise), catches any synchronous exception and turns it into a rejection.

    var p = BetterPromise.try(function(){
       undefined(42);
    });
    p.catch(console.log);
    // TypeError: undefined is not a function
  • BetterPromise.deferred(..) (static helper) constructs an instance of the promise, but also extracts its resolve(..) and reject(..) capabilities, and returns all 3 in an object (aka, a "deferred").

    var { pr, resolve, reject } = BetterPromise.deferred();
    
    pr.then(console.log);
    resolve(42);
    // 42
  • BetterPromise.lift(..) (static helper) lifts an error-first, callback-last style function to be BetterPromise returning instead.

    var readFile = BetterPromise.lift(fs.readFile);
    readFile("/path/to/file.txt")
    .then(printContents);
  • BetterPromise.control(..) (static helper) wraps a function so that when called, it first creates an AbortController instance, passes in its signal as the first argument to the original function, and returns a controller object that has both a pr for the function's completion, as well as a cancel(..) to send the abort signal. Ostensibly, the original function can then observe/respond to that passed-in signal and do something appropriate with it, like canceling its own behavior, passing it to fetch(..) to abort an Ajax call, etc.

    async function main(signal,url) {
       signal.addEventListener("abort", .. );
    
       // ..
                                 
       var resp = await fetch(url, { signal });
                                     
       // ..
    }
    
    var fn = BetterPromise.control(main);
    var { pr, cancel } = fn("http://some.url");
    
    pr.then(..);
    
    // later
    cancel();  // sends cancelation signal into `fn(..)`
class BetterPromise extends Promise {
static resolve(v) {
return new this[Symbol.species](function c(res){
res(v);
});
}
static reject(v) {
return new this[Symbol.species](function c(res,rej){
rej(v);
});
}
static try(fn) {
return new this[Symbol.species](function c(res){
res(fn());
});
}
static deferred() {
var resolve;
var reject;
var pr = new this[Symbol.species](function c(res,rej){
resolve = res;
reject = rej;
});
return { resolve, reject, pr, };
}
static lift(fn) {
return (...args) => {
return new this[Symbol.species](function c(res,rej){
fn(...args,function cb(err,any){
if (err) rej(err);
else res(any);
});
});
};
}
static control(fn) {
return (...args) => {
var token = new AbortController();
var pr = this[Symbol.species].try(function c(){
return fn(token.signal,...args);
});
return { pr, cancel: token.abort.bind(token), };
};
}
constructor(fn) {
super(function c(res,rej){
var resolution;
var rejection;
var syncComplete = false;
var resolveCalled = false;
var rejectCalled = false;
try {
fn(
function resolve(v){
if (!syncComplete) {
resolveCalled = true;
resolution = v;
}
else {
res(v);
}
},
function reject(e){
if (!syncComplete) {
rejectCalled = true;
rejection = e;
}
else {
rej(e);
}
}
);
syncComplete = true;
if (resolveCalled) res(resolution);
else if (rejectCalled) rej(rejection);
}
catch (err) {
rej(err);
}
});
this.__fulfilled_handlers = new WeakMap();
this.__rejected_handlers = new WeakMap();
this.__resolved_handlers = new WeakMap();
}
then(origOnFulfilled,origOnRejected) {
var thenArgs = [];
if (typeof origOnFulfilled == "function") {
let onFulfilled = (...args) => {
if (this.__fulfilled_handlers.has(origOnFulfilled)) {
return origOnFulfilled(...args);
}
return args[0];
};
this.__fulfilled_handlers.set(origOnFulfilled,true);
thenArgs.push(onFulfilled);
}
else if (origOnFulfilled instanceof Promise) {
thenArgs.push(() => origOnFulfilled);
}
else {
thenArgs.push(undefined);
}
if (typeof origOnRejected == "function") {
let onRejected = (...args) => {
if (this.__rejected_handlers.has(origOnRejected)) {
return origOnRejected(...args);
}
return args[0];
};
this.__rejected_handlers.set(origOnRejected,true);
thenArgs.push(onRejected);
}
else {
thenArgs.push(undefined);
}
return super.then(...thenArgs);
}
catch(origOnRejected) {
return this.then(undefined,origOnRejected);
}
finally(origOnResolved) {
if (typeof origOnResolved == "function") {
let onResolved = () => {
if (this.__resolved_handlers.has(origOnResolved)) {
return origOnResolved();
}
};
this.__resolved_handlers.set(origOnResolved,true);
return super.finally(onResolved);
}
else {
return super.finally();
}
}
thenLog() {
return this.then(function then(v){
console.log(v);
return v;
});
}
catchLog() {
return this.catch(e => {
console.error(e);
return this.constructor[Symbol.species].reject(e);
});
}
unthen(origOnFulfilled,origOnRejected) {
if (typeof origOnFulfilled == "function") {
if (this.__fulfilled_handlers.has(origOnFulfilled)) {
this.__fulfilled_handlers.delete(origOnFulfilled);
}
}
if (typeof origOnRejected == "function") {
if (this.__rejected_handlers.has(origOnRejected)) {
this.__rejected_handlers.delete(origOnRejected);
}
}
return this;
}
uncatch(origOnRejected) {
return unthen(undefined,origOnRejected);
}
unfinally(origOnResolved) {
if (typeof origOnResolved == "function") {
if (this.__resolved_handlers.has(origOnResolved)) {
this.__resolved_handlers.delete(origOnResolved);
}
}
}
}
@transitive-bullshit
Copy link

Interesting thought experiment :)

My main feedback is that keeping the API surface of Promise as small as possible is important.

With that being said, of these changes, I like Promise#finally and Promise.try.

As for Promise#unthen etc, I get what you're saying with the majority use case, but the naming is awkward and imho would be confusing for peeps.

The one missing item which I'm surprised you didn't include would be concurrency control for evaluating multiple Promise-returning functions which is what I want 95% of the time I would've used Promise.all. I typically use p-map to accomplish this, but given how often it's used, in terms of this thought experiment, I would love to add Promise.map as a first-class version of this functionality.

Cheers!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment