One does not simply extend Promise

Yesterday I wanted to answer with some elegant code to a question: "how can we abort Promises?"

// it's as easy as ... oh, wait, WUT?
class Disposable extends Promise {
  constructor(callback, {signal}) {
    super((resolve, reject) => {
      signal.addEventListener('abort', reject);
      callback(resolve, reject);
    });
  }
}

The idea was to use AbortController:

const controller = new AbortController();
new Disposable(
  (resolve, reject) => {
    setTimeout(resolve, 1000, 'OK');
  },
  controller
)
  .then(console.log)
  .catch(console.error);

// abort before Promise is resolved
setTimeout(() => controller.abort(), 500);

The issue is that as soon as new Disposable(...).then(...) happens:

VM261:3 Uncaught TypeError: Cannot destructure property 'signal' of 'undefined' as it is undefined.

I thought it was an issue with the AbortController itself but it looks like even the simplest new Disposable(Object, {signal: 123}).then(console.log) , removing the addEventListener part, fails.

On the other hand, this works like a charm but it doesn't brand the Promise:

function Disposable(callback, {signal}) {
  return new Promise((resolve, reject) => {
    signal.addEventListener('abort', reject);
    callback(resolve, reject);
  });
}

Can anyone please explain why Promise cannot be extended with ease like any other builtin class?

Thank you!

Well ... I'd be damned but I've no idea why this module code, which is almost identical, works but the one exposed in here doesn't ... it's the weirdest thing I've witnessed around extend and I am not sure I am being blind around where exactly is the issue, but I'm glad I've managed to make it work in my module.

You were almost there with

The issue is that as soon as new Disposable(...).then(...) happens:

When calling then(), another promise is returned to allow you to continue the chain. The kind of promise returned will match the promise original promise. So the return value for Disposable#then() is another Disposable promise. Promises will use the constructor, or species if available, to determine what kind of promise then() returns.

The problem lies in the fact that you don't have control over the creation of these then()-created promises so they'll always be called with the default constructor arguments (a single executor) meaning the controller argument will be coming in undefined. Being undefined, {signal} will fail and give you your destructure error.

The code you linked to avoids this problem by providing a default parameter for the controller

constructor(callback, {signal} = {}) {

This will let signal be undefined if the argument is undefined instead of causing an error.

Another option would be to set the species of Disposable to a standard Promise

class Disposable extends Promise {
  static [Symbol.species] = Promise;
  ...

but I think we're not supposed to like species anymore.

beside the fact mentioning Symbol.species as solution looks awkward, as that's discouraged on MDN itself, there's still something I can't explain ... the destructuring on the second parameter happens at the right constructor level, nowhere else, and I believe extending super signatures is a very common OOP pattern ... now this is non-explainable to me in the class related specs ... please look very close:

// we test the exact same scenario
function test(Class) {
  const controller = new AbortController;
  new Class(
    (resolve) => {
      setTimeout(resolve, 1000, 'OK');
    },
    controller
  ).then(console.log, console.error);
  setTimeout(() => {
    controller.abort('because');
  }, 500);
}

// this class has an extra param on its signature
class FixedSignature extends Promise {
  constructor(callback, {signal}) {
    super((resolve, reject) => {
      signal.addEventListener('abort', reject);
      callback(resolve, reject);
    });
  }
}

// this class has an *optional* param on its signature
class DynamicSignature extends Promise {
  constructor(callback, {signal} = {}) {
    super((resolve, reject) => {
      if (signal)
        signal.addEventListener('abort', reject);
      callback(resolve, reject);
    });
  }
}

Here the surprising horror show to me:

test(FixedSignature);
// Uncaught TypeError: Cannot destructure property 'signal' of 'undefined' as it is undefined.

test(DynamicSignature);
// all good, the demo works as expected

Not convinced yet? Let's take the constructor fixed VS dynamic arity to anything else:

function test(Class) {
  console.log(new Class(1, {add: 2}).valueOf());
}

class FixedSignature extends Number {
  constructor(init, {add}) {
    super(init + add);
  }
}

class DynamicSignature extends Number {
  constructor(init, {add} = {}) {
    super(init + (add || 0));
  }
}

test(FixedSignature);   // 3
test(DynamicSignature); // 3

No error, no problems, no nothing ... so I'll ask again: can anyone please explain to me what is going on with the Promise extend?

It looks like internally it dictates the signature on any extender and really screws up anything closure / function / class related for no good reason, and Symbol.species are not even needed to show why the current state is completely broken ... I've also just realized my module works by accident, because I've decided to make it a drop in replacement, hence with an optional extra argument, but this makes literally no sense to me.

Thank you.

Oh My Gosh ... I just tested this optional param instead:

class DynamicSignature extends Promise {
  constructor(callback, {signal} = {}) {
    super((resolve, reject) => {
      console.log(Math.random()); // <-- !!!!
      if (signal)
        signal.addEventListener('abort', reject);
      callback(resolve, reject);
    });
  }
}

const controller = new AbortController;
new DynamicSignature(
  (resolve) => {
    setTimeout(resolve, 1000, 'OK');
  },
  controller
).then(console.log, console.error);

// 0.973273550250459
// 0.8496197204519471
// sorry ... WHAT?

And it logs twice the Math.random() whatever returned value ... so now I am more confused than ever ... how the heck does Promise extend works behind the scene?

If from user-land I create a class the needs a callback as argument in no world or circumstances I would ever trigger that more than once, for security / side-effect / correctness / expectations sake ... can anyone please tell me why is that a Promise extend would ever invoke its mandatory super(callback) twice?

This is becoming even more awkward than anticipated ...

OK, maybe I under-estimated the importance of Symbol.species here ... indeed this works as expected and at least I might understand where the issue comes from:

class DynamicSignature extends Promise {
  static [Symbol.species] = Promise;
  constructor(callback, {signal} = {}) {
    super((resolve, reject) => {
      console.log(Math.random()); // <-- !!!!
      if (signal)
        signal.addEventListener('abort', reject);
      callback(resolve, reject);
    });
  }
}

const controller = new AbortController;
new DynamicSignature(
  (resolve) => {
    setTimeout(resolve, 1000, 'OK');
  },
  controller
).then(console.log, console.error);

// logs the Math.random() once only

This all works as expected but then again: "why is species considered bad when it's the only solution to this kind of issue?"

Thanks for highlights on where species is actually undesirable, yet I think there's no other way to fix this Promise extend gotcha here.

P.S. using Symbol.species makes the extend lose the brand when passed along ... not that it's super useful as detail but it's still pretty weird how this dance works. In short new Extend(callback) instance of Extend will be true, but new Extend(callback).then(doStuff) instanceof Extend will be false.

@senocolar's answer is on point.

There are 2 important factors:

  • What kind of Promise you want when you do promise.then()? Your derived promise or the native Promise?
  • Promise constructors are part of the API, and expected to be called with a single executor argument.

In order to construct the object of the correct kind, Promise.prototype.then goes through the SpeciesConstructor algorithm to create a related promise object. This is necessary to propagate custom promises, like ones that do tracking. This mechanism is common to most built-ins whose methods return a new instance of themselves.

If you don't need to propagate your custom promise through .then(), you can simply set CustomPromise[Symbol.species] to null or undefined. An alternative is to set the CustomPromise.prototype.constuctor to undefined or Promise if you don't want to use Symbol.species.

And as mentioned, the Promise's API contract includes the constructor arguments, so derived Promises must be able to handle a single executor argument.

my main surprise is around the fact the super callback executes more than once when a then is invoked and that means side-effecting on the passed callback too, which never happens to original promises, so basically promises are not extensible by default, indeed I had to change my module code:

function CustomPromise(callback, {signal} = {}) {
  return new Promise((resolve, reject) => {
    if (signal)
      signal.addEventListener('abort', new Handler(resolve, reject));
    callback(resolve, reject);
  });
}

Object.setPrototypeOf(CustomPromise, Promise).prototype =
  Promise.prototype;

export {CustomPromise};

This is the only way I can brand check new CustomPromise(callback) instanceof CustomPromise and also new CustomPromise(callback).then(other) instanceof CustomPromise ... as CustomPromise is now just a facade of builtin Promise.

It's not possible to have both statement true without invoking the passed callback twice or more with all the undesired shenanigans and side-effects that means, so I think (yet again) Promise class should be a final one, because the current specification around re-invoking the constructor and its callback with it, can hurt developers ignoring this very specific detail around Promise extend.

I guess I am suggesting that any builtin constructor accepting a mandatory callback should never invoke such callback until its latest extend is reached, otherwise Promise looks like a great extend to avoid in the wild due it's numerous issues with the current state.

Where do you see it invoked more than once for then? In your example, the constructor, or the executor called by the constructor is called once per promise instance/construction: Once when you explicitly call new DynamicSignature and once when you call .then(). Given the built-ins behavior of propagating derived classes, I don't see how it's surprising for your derived constructor to be called twice.

it's surprising at the extend level ...

const sideEffect = {
  get value() {
    return Math.random();
  }
};

class Sub extends Promise {
  constructor(callback) {
    super((resolve, reject) => {
      console.log(sideEffect.value);
      callback(resolve, reject);
    });
  }
}

new Sub(Object).then(Object);
// 0.18309739180595974
// 0.21775262118283756

I don't have extra arguments there but the super callback executes more than once ... actually, once per each then and once per each extend ... I understand the super is executed every time a new instance is created, but why is the callback called ever more than once?

I guess my argument is: if a builtin constructor accepts a callback as mandatory argument to be executed, make it execute once down the prototypal inheritance and be done with it?

Or better, what is the reason, in the Promise case, the callback passed to the super is executed every time? I understand that's expected with species, create a new instance hence invoke the constructor again on repeated derived instances, but what are the concrete benefits of doing so?

I guess my quest here is to find a way to have a .then(callback) that returns same specie but doesn't pass through the constructor, as the constructor itself, when it comes to promises, should never execute its callback more than once, and a retrieved then (branched) instance doesn't want to ever re-run the initial constructor, it wants to return an instance that resolves when the initial constructor is done ...

in other words, if invoking the constructor each time a then is retrieved, by specs, why the following promise wouldn't log random number twice?

new Promise(
  (res, rej) => {
    console.log(Math.random());
    setTimeout(res, 1000, 'OK');
  }
).then(console.log);

if then passes through the Promise constructor that received that callback, why I cannot see 2 Math.random() ? This is the counter-intuitive part around extending a Promise, and I am sure the current behavior is unexpected in practice, not in terms of how a super works, and there's no reasonable way to prevent the current behavior and preserve the Promise kind.

In practice, I think this is very hard to explain out there:

const sideEffect = {
  get value() {
    return Math.random();
  }
};

class Sub extends Promise {
  constructor(callback) {
    // expected callback invoke *once* up there
    super((resolve, reject) => {
      console.log(sideEffect.value);
      callback(resolve, reject);
    });
  }
}

class SubSub extends Sub {
  constructor(callback) {
    // expected callback invoke *once* up there
    // because super calls it once, expected max 2 invokes
    super((resolve, reject) => {
      console.log(sideEffect.value);
      callback(resolve, reject);
    });
  }
}

// expected 2 Math.random()
new SubSub(Object).then(Object) instanceof SubSub;
// true ... but ...

// got 4 Math.random()
// 0.8782228881162686
// 0.6226237763466325
// 0.8762779980398552
// 0.6806218405445226

As summary: shouldn't the constructor callback, for same constructor that derives its own thenable, trigger the callback never more than once? If not, what's the practical use case that supports current state of affairs? and why is defaulting to the super species is the only way to solve this?

Moreover: why a then() should involve its constructor ever, when it's a branched derived promise of what its constructor did once?

Once again, the executor already executes only once per construction ...

Where do you see otherwise?

this reply explains my concerns which, at this point, are all around .then(callback) or even .catch(callback) specifications.

To put it in daily words, imagine we start an email conversation, and every time there's a followup the initial email is not just embedded as beginning of the conversation, is presented as intermediate reset of such conversation, as if no follow up (then) discussions happened in between ... that is: every time a follow up comes after, you get to re-read, re-answer, re-execute, the beginning of the conversation, even if it moved to other topics, as that's what a .then(doStuff) is supposed to do ...

Do you find this mental model helpful for anything at all?

Can you please show me a single use case where the current state around .then promises is desired?

Thanks!

What you are seeing is .then() calling the promise constructor with a new executor. It's basically creating a "deferred", aka constructing a new promise and extracting its resolution capability (capturing its resolve and reject).

nope, what I see is an origin executing every time a suborigin follow up happens, as explained in this example.

what I see is an issue in then or catch specification, where the source of the Promise leaks and re-execute every time, as side-effect, some code runs then even if it was for fun, just to re-execute the source of the intent/logic/promise the developer had.

Imagine a fetch request re-executing every time a then is used from its original intent.

Can you see this has no reason to exist in Promise programming, or can you please give me a single use case this is desired?

Thanks!

It's very simple to explain:

  • promise methods that return new promise objects call the species constructor (which created the current instance). This is a behavior of all built-in methods returning instances of themselves.
  • the base promise constructor always synchronously calls the executor passed as argument, and only once (it doesn't store it anywhere)
  • When a promise constructor is internally called, it's always called with a new executor whose job is to capture the promise resolution capability (aka create a deferred). That is how the promise API works

gonna re-quote this, as I really would like an answer to this case, or see use cases where current state is desirable, thanks.

I'm not sure what else I can say. Please re-evaluate what you're observing, you didn't reach the correct conclusion on what is actually happening.