arthurfiorette / proposal-safe-assignment-operator

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

Please re-consider the "recursive unwrapping" design aspect #6

Open getify opened 4 weeks ago

getify commented 4 weeks ago

I'm aware that the recursive unwrapping is a convenience, and mirrors the same behavior in promises. However, I would like to urge its reconsideration -- or at the very least, register the objection here in an issue, for posterity sake.

Back Story: Promises

The fact that I cannot carry a Promise inside another Promise (Promise<Promise>) creates an overfit in locality of result handling. By that I mean, if I await or then() a promise, I'm forced to deal with the ultimate result of that at that point in the code. I cannot hold multiple layers of result wrapped together, and progressively unwrap and handle those layers one at a time across a call-stack.

To put this in other terms, the recursive unwrapping (or refusal to even nest!) design aspect of promises is what makes Promise not compatible with the monadic laws -- despite all the well-intentioned blog posts that claim JS promise is a monad. This design choice is what breaks down a variety of techniques that are established around those guarantees (in the world of FP programming).

Undoubtedly, convenience is a strong driver for that design. Probably also performance.

But I think the case could/should still have been considered that, it would have been preferable if there was an option to nest Promises, and to unwrap them one level at a time, even if the default behavior might have been to recursively flatten them out.

Here We Go Again

So now we arrive at the present ?= proposal, and the implied [error, data] tuple type. If we handwave a bit, this type is basically an Either monad. Which is cool.

The explainer illustrates we can manually construct nested tuples, just like you could do with real Either instances. That's cool, and an improvement over the Promise type which didn't allow the nesting at all (it basically unwraps/flattens at resolve() time). I assume that if the tuple in question were an actual concrete value type of the language (as Promise is), the design would probably prevent such nesting.

Since we can nest these values, we're closer to being able to take advantage of the relevant monadic guarantees and design patterns. But then the ?= recursive unwrapping kicks the legs out from underneath us. If we use ?=, we lose the monadic'ness. We can make Either piggyback on the tuple type's design, but have to use custom userland functions for the handling, instead of the code readability and attractiveness of ?= operator itself. That's disappointing.

Reconsider?

I'm not asking to discard the recursive unwrapping entirely, but rather, could we possibly have both options available, one where the recursive unwrapping is done (for those who prefer the convenience), and one where the unwrap is not recursive?

I could bikeshed here on ?= vs ?*= as a pair of operators for this purpose. But before that's relevant to discuss, I just wanted to raise the question if we could reconsider this design decision before it's too far baked?

arthurfiorette commented 4 weeks ago

Sure! this was my first approach when trying to polyfill the idea into actual JS. I'm also afraid that we change to try syntax instead of ?=, which, following the https://tc39.es/proposal-explicit-resource-management precedent of using and await using, we will probably end up with try and try await which could use two different symbols.

Something like Symbol.result and Symbol.asyncResult, just like Symbol.dispose and Symbol.asyncDispose.

JAForbes commented 4 weeks ago

?*= or try * feels pretty intuitive, I like that idea @getify

Also implementing the recursive part could easily be a later standard which would hopefully get this accepted and over the line sooner?

rbalicki2 commented 3 weeks ago

Recursive unwrapping makes this feature non-composable and not usable in a generic context. Consider looking up a user in a database. I may have a DbAccessError if the database is down, or a AuthError if the user has blocked me, or I may receive a user. I would express this as Result<Result<User, AuthError>, DbAccessError>. Now, consider a generic function that handles the db access error:

function handleDbAccessError(doDatabaseAction: () => Result<T, DbAccessError>) {
  const [err, value] = doDatabaseAction(primaryDatabase);
  if (err != null) {
    // is err a DbAccessError? NO! It's actually **anything** because of the recursive handling
    alert('Sadness, database is not accessible: ' + err.accessException.toString()) // oops this throws, because
    // auth error doesn't have .accessException
  }
}

(Re: @JAForbes's point. This illustrates why we could never add recursive handling afterward.)

getify commented 3 weeks ago

@rbalicki2

Either changing from recursive to non-recursive, or vice versa, would certainly be an intolerable hard breaking change. However, I think @JAForbes meant that we could add one of these later, as an additional capability, not that we'd make a breaking change.

rbalicki2 commented 3 weeks ago

Ah, understood. Thank you for the clarification!

andersk commented 3 weeks ago

As I understand it, the motivation for recursive unwrapping is to make ?= await getPromise() work. But even there, it does so at the wrong level. Consider:

async function getPromise() {
  return 1;
}

const [error, data] ?= await getPromise();
console.log(data); // 1

const [error, promise] ?= getPromise();
const data = await promise; // plain old = here
console.log(data); // [null, 1]

By separating the function call from the await and only using ?= with the former, I’ve clearly indicated that I expect to process errors only from the former—yet the recursive unwrapping has already happened and mangled the promise to process errors from the latter.

callionica commented 3 weeks ago

The proposal seems focused on promises, but I don't understand how it can work with functions that return functions (any kind of function). As written, it seems like you can't call a function that returns a function without the returned function being called automatically (and so on until there's a result that's not a function). That would be surprising and unwanted behaviour.

arthurfiorette commented 3 weeks ago

I agree with this and it was only my first idea around how to support async functions. I guess in the end this will also be solved if we migrate to this syntax