Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
getify opened this issue Aug 15, 2024 · 8 comments
Open

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

getify opened this issue Aug 15, 2024 · 8 comments
Labels
enhancement New feature or request

Comments

@getify
Copy link

getify commented Aug 15, 2024

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
Copy link
Owner

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
Copy link

?*= 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
Copy link

rbalicki2 commented Aug 17, 2024

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
Copy link
Author

getify commented Aug 17, 2024

@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
Copy link

rbalicki2 commented Aug 17, 2024 via email

@andersk
Copy link

andersk commented Aug 19, 2024

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
Copy link

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
Copy link
Owner

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

6 participants