Skip to content

Instantly share code, notes, and snippets.

@getify
Last active October 15, 2020 01:44
Show Gist options
  • Star 62 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • 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);
}
}
}
}
@ademidoff
Copy link

Very smart indeed. Do you think ‘var’s are more appropriate here rather than ‘let’ or ‘const’? Why so?

@getify
Copy link
Author

getify commented May 26, 2018

@atymchuk I use var for variable declarations at the top level of a function, and let for variable declarations inside of blocks. IMO, var signals "will be used across the whole function" while let signals "will be used only in this limited scope of this block". Both signals are important, but using let for both degrades that signal difference.

Copy link

ghost commented May 28, 2018

@getify

quick aside: I read YDKJS & am currently working through Functional-Light JavaScript. Thank you so much for those awesome tomes of knowledge.

Now onwards.

I been heavily researching how one can make native promises more effective.

Here is something sitting inside my head for awhile, and I think I almost see this in your deferred method here. The real question is: Is there a way to make a Promise something like a Future? e.g. like in Python or Rust (for reference, because its cleaner than the Python doc IMO, https://docs.rs/futures/0.2.1/futures )

For instance, say you want to call an AJAX request at the beginning of a page load, but want the results to be held in a Future until you're ready to access them. The benefit here is that you would call the say, Promise.Future early in the code, fire off the async AJAX request, but somewhere else you call future.result() and retrieve those bits.

Aslo, to address var vs let.

Why not use const for variables in a limited scope and let for everything else? Conveys the same meaning semantically without confusion. Just curious. Also less text editors will freak out about it :)

@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