In my previous post where I created a class (ResolvablePromise) that inherits from Promise I mentioned this:
I thought that it would be important that the different methods in a ResolvablePromise (then, catch, finally) also returned a ResolvablePromise, rather than a standard Promise, so that we can apply the resolve/reject methods to the promises produced during chaining. I did an implementation and came across an odd problem, caused by the fact that in recent JavaScript versions that's no longer necessary. When subclassing a Promise the then-catch-finally methods of parent Promise already return an instance of the Child class!
That was not like that a few years back. I guess Promises had not been initially designed with the idea of you inheriting from them, so the then, catch, finally methods in the Promise class would always return a Promise, regardless of whether they were being invoked from a child. Well, this is my expected behaviour, I think it's very unlikely that a method (I'm not talking just about JavaScript, but in general) checks if it's being invoked from a derived class (though we've seen that for method chaining it's a really interesting pattern). So, when I created my derived ResolvablePromise I naturally decided to override its then/catch/finally methods to return another ResolvablePromise. This is how I implemented it:
class ResolvablePromise extends Promise {
constructor(executor) {
let _resolve, _reject;
let executorWrapper = (res, rej) => {
_resolve = res;
_reject = rej;
executor(res, rej);
};
super(executorWrapper);
// now "this" is available
this.resolve = _resolve;
this.reject = _reject;
}
then(onFulfilled, onRejected) {
let pr1 = ResolvablePromise(() => {});
super.then(onFulfilled, onRejected).then(pr1.resolve, pr1.reject);
return pr1
}
catch(onRejected) {
let pr1 = ResolvablePromise(() => {});
// I have to use .then() here as .catch resolves the promise (unless it throws)
return super.catch(onRejected).then(pr1.resolve, pr1.reject);
return pr1;
}
finally(onFinally) {
let pr1 = ResolvablePromise(() => {});
return super.finally(onFinally).then(pr1.resolve, pr1.reject);
return pr1;
}
Let's go through the "then" implementation. I create a new ResolvablePromise, that is what I'll end up returning. To execute the normal "then" logic (that will attach the onResolve/onReject handlers to the Promise internals) I invoke the parent "then", and I chain to it the resolution of the new ResolvablePromise. Notice how for "catch" and "finally" I invoke the corresponding parent methods, and then I chain to them again via "then", as the parent "catch" will resolve its promise (unless it decides to throw again), so it's a "then" what I have to chain, not another "catch".
I asked a GPT to review the above code and it came up with some style improvement. It rewrote my "then" method like this:
then(onFulfilled, onRejected) {
return ResolvablePromise((res, rej) => {
super.then(onFulfilled, onRejected).then(res, rej);
});
}
Basically it puts my "invoke parent and chain promise resolution" logic into an executor that it passes to the ResolvablePromise constructor. What was odd to me is that it's using super from an Arrow Function, not from a Child method, so what? I know that Arrow Functions have lexical bindings for "this" and for "arguments", but I had missed that also for super. The arrow function is defined in a Child method, so when it tries to use super it will search for it in the Scope chain, finding it in that Child method. I put below the full, GPT approved, version
class ResolvablePromise extends Promise {
constructor(executor) {
let _resolve, _reject;
let executorWrapper = (res, rej) => {
_resolve = res;
_reject = rej;
executor(res, rej);
};
super(executorWrapper);
// now "this" is available
this.resolve = _resolve;
this.reject = _reject;
}
then(onFulfilled, onRejected) {
return ResolvablePromise((res, rej) => {
super.then(onFulfilled, onRejected).then(res, rej);
});
}
catch(onRejected) {
return ResolvablePromise((res, rej) => {
super.catch(onRejected).then(res, rej);
});
}
finally(onFinally) {
return ResolvablePromise((res, rej) => {
super.finally(onFinally).then(res, rej);
});
}
}
When testing it I was getting an infinite recusion!!!??? After a while I realised that when doing super.then().then() the second then() was being invoked on a ResolvablePromise rather than on Promise, hence the recursion. This felt so odd to me, but investigating a bit I found what I explained at the beginning, that modern JavaScript versions do that in Promises methods, return an instance of the Child class. When asking about this issue to the GPT that had reviewed my code and provided that "style" improvement, he was well aware about this JavaScript evolution (so it would have been nice if he had already told me when reviewing my initial code).
The information from the GPT was very clear, and it also explains that if for some reason we want the old behaviour for our derived class, we can get it by defining a static Symbol.species in our class.
When you call .then() on a promise, the JavaScript engine internally uses the SpeciesConstructor operation to determine what constructor to use for the new promise it returns.
Here's the logic in simplified terms:
- Get the constructor of the current promise:
let C = this.constructor;
- Check for Symbol.species:
let species = C[Symbol.species];
- Use species if defined, otherwise fall back to C:
let resultConstructor = species !== undefined ? species : C;
If you want to check some official documentation, MDN deals with this topic in its subclassing built-ins section. There you can see that this behaviour applies also to the map, filter... methods of Arrays.
If you wonder if for example Kotlin (for the JVM) features something like this, the answer is NO. The map, filter... extension methods that we can apply to Iterables do not check at all the specific Iterable instance that they are being applied on. They return an ArrayList instance (that's an implementation detail, the signature just shows a List interface. From the Kotlin source code:
public inline fun Iterable.filter(predicate: (T) -> Boolean): List {
return filterTo(ArrayList(), predicate)
}
public inline fun Iterable.map(transform: (T) -> R): List {
return mapTo(ArrayList(collectionSizeOrDefault(10)), transform)
}