arthurfiorette / proposal-safe-assignment-operator

Draft for ECMAScript Error Safe Assignment Operator
https://arthur.run/proposal-safe-assignment-operator/
MIT License
1.41k stars 14 forks source link

Special-casing particular expression types is a footgun #12

Open pie-flavor opened 2 months ago

pie-flavor commented 2 months ago

This spec describes that [err, res] ?= func(x) is evaluated like func.@@result(x). However, it also describes that [err, res] ?= obj is evaluated like obj.@@result(). This would imply that [err, res] ?= (func(x)) is evaluated like func(x).@@result(). One can easily imagine a code formatting style, preprocessor, or bulk-editing oversight that creates a case like that.

Specifying that non-existing @@result produces TypeError would protect against this case, except that because Promises are handled via a general rule of recursion, no such error would be thrown for a Promise-returning function (or other @@result-implementing value).

This can produce a line of code that the author expects will never throw, but can throw.

More generally, this syntax breaks other expectations about general programming, such as that evaling an expr is no different from assigning it to a variable and then evaling the variable. An example of a function call being treated specially would be Go's defer f(x) evaluating x immediately instead of at the time of invocation, but Go counterbalances this by forbidding any other kind of expression so if you screw it up you get an immediate error.

In my opinion, if a function call expr is treated specially, all other types of expr should be forbidden. An element of syntax can be an alternative way to call a function, or an operator on the result of an expression, but should never be both. (This implies processing await foo(x) directly as a second case, instead of through recursion.)

anacierdem commented 2 months ago

This particular example is still confusing me:

function example() {
  return {
    [Symbol.result]() {
      return [new Error("123"), null]
    },
  }
}

const [error, result] ?= example() // Function.prototype also implements Symbol.result
// const [error, result] = example[Symbol.result]()

// error is Error('123')

This implies it is evaluated as: example.@@result() but the error comes from the object returned from the function. It is also mentioned that Function has a Symbol.result but how that actually works? The handler in Function's prototype still needs to execute the actual function, get the return value and return it.

How will the function instance passed on to the handler on the prototype? Is it an implied mechanism similar to this being set on function calls directly on a class's instance?

class A {
  internalValue = 3;
  myMethod() {
    return this.internalValue;
  }
}

const a = new A();

a.myMethod(); // works as expected - "this" is set to "a"

const b = a.myMethod;
b(); // doesn't have a reference to the instance

What is the equivalent mechanism for the result symbol? It should be possible to implement a custom version of this mechanism, be it for poly-filling reasons or other.

Edit: the class example is maybe somewhat irrelevant, there is no way we loose track of the function for the new operator, but I am asking how it is passed in to the result handler? Maybe this is how it should be handled?

anacierdem commented 2 months ago

Ok, I think it is obviously based on this again (also see the polyfill provided in the repo), I just missed it previously. See this implementation:

const result = Symbol("result")

function example() {
  return {
    [result]() {
      return [new Error("123"), null]
    }
  }
}

Function.prototype[result] = function() {
  return [this()[result](), null];
}

const [value, error] = example[result]();

console.log(value, error);

For the object version:

const result = Symbol("result")

const example = {
  [result]() {
    return [new Error("123"), null]
  }
};

Object.prototype[result] = function() {
  return [this[result](), null];
}

const [value, error] = example[result]();

console.log(value, error);

Notice the difference between the two implementations - we are calling it if it is a function and not if it is an object. That special casing is the reason why I agree with @pie-flavor.

arthurfiorette commented 2 months ago

I guess this will be resolved if this new syntax gets accepted as the new one.