Differences between Promise.prototype methods with regard to what constitutes what constitutes compatible receiver

# Darien Valentine (a month ago)

In Promise.prototype.then:

  1. Let promise be the this value.
  2. If IsPromise(promise) is false, throw a TypeError exception. [ ... ]

In Promise.prototype.finally:

  1. Let promise be the this value.
  2. If Type(promise) is not Object, throw a TypeError exception. [...]

In Promise.prototype.catch:

  1. Let promise be the this value.
  2. Return ? Invoke(promise, "then", « undefined, onRejected »).

First, this means that only then requires the this value to be a Promise:

for (const key of [ 'then', 'finally', 'catch' ]) {
  try {
    Promise.prototype[key].call({
      then: () => console.log(`${ key } doesn’t brand check its this value`)
    });
  } catch (err) {
    console.log(`${ key } does brand check its this value`);
  }
}

// > then does brand check its this value
// > finally doesn’t brand check its this value
// > catch doesn’t brand check its this value

Second, note that Invoke uses GetV, not Get. Thus:

for (const key of [ 'then', 'finally', 'catch' ]) {
  try {
    String.prototype.then = () =>
      console.log(`${ key } casts this value to object`);

    Promise.prototype[key].call('foo');
  } catch (err) {
    console.log(`${ key } doesn’t cast this value to object`);
  }
}

// > then doesn’t cast this value to object
// > finally doesn’t cast this value to object
// > catch casts this value to object

On reflection, I think I see the logic to this:

  • Promise.prototype.then ends up executing PerformPromiseThen, which requires its first argument to be a native promise object.
  • Promise.prototype.finally ends up executing SpeciesConstructor, which requires its first argument to be an object.
  • Promise.prototype.catch does neither.

However the inconsistency within this trio seems pretty odd to me. I suppose I would have expected them all to be as constrained as the most constrained method needed to be for the sake of uniformity, given that they constitute a single API. Conversely, if the goal was for each method to be exactly as lenient as is possible, then finally seems to be over-constrained; it seems like C could have just defaulted to Promise in cases where SpeciesConstructor wasn’t applicable, making it as lenient as catch.

I wasn’t able to find prior discussion about this, though it’s a bit hard to search for, so I may be missing it. Do these behaviors seem odd to anyone else, or is it what you’d expect?

# Jordan Harband (a month ago)

This is intentional - catch delegates to then, so that a subclass that overwrites then doesn't have to also override catch (and the same for finally, which also calls into then).

# Darien Valentine (a month ago)

That makes sense for sure, but I don’t think subclassing is impacted by receiver constraints in this regard either way, since subclasses will still be “IsPromise” promises. What is affected is portability of the methods to “non-IsPromise” thenables. Catch not requiring an IsPromise receiver makes sense from that angle:

OffbrandPromise.prototype.catch = Promise.prototype.catch;

But if portability to non-native, non-Promise-subclass thenables is the goal, I’d still have expected finally to be the same “level” of generic as catch. Neither require their receiver to be an IsPromise-promise, but catch doesn’t even require its receiver to be an object, while finally does. This is a very minor thing obviously, but I wondered if it might still be web safe at this point to make catch require its receiver to be an object like finally does. It would be more consistent, and it’s pretty hard to imagine the current ability to call catch on a primitive, as in the second example above, being a useful behavior.

# Jordan Harband (a month ago)

That seems reasonable, although I'm not sure of the value of that consistency. It'd have to be proven to be web-compatible - ie, if anyone was adding a .then to a primitive prototype, they might be relying on the current behavior of .catch.