tc39 / proposal-do-expressions

Proposal for `do` expressions
MIT License
1.12k stars 14 forks source link

How should this proposal work with pattern matching? #77

Open theScottyJam opened 1 year ago

theScottyJam commented 1 year ago

The pattern matching proposal is currently being designed with the expectation that do expressions will help fill in some functionality gaps (in its current form, it is encouraging the use of IIFEs until do expressions get standardized).

We assume that do expressions will mature soon, which will allow users to put multiple statements in an RHS; today, that requires an IIFE.

I think it's worth discussing how we want these proposals to live together, because if we assume they'll coexist, it may influence some of our design choices in regards to do expressions.

My biggest concern that I mostly want to focus on is the following: If pattern matching is being used like a statement (i.e. its completion value is getting ignored), then do expressions in turn will be getting used like a statement (i.e. nothing will use its completion value). Do expressions, as currently designed, is assuming that this will never happen in any practical code, which is why the design was fine with disallowing for loops and what-not at the end of the do block. Here's an example of what I'm talking about:

async function updatePassword(username, opts) {
  match (opts) {
    when ({ password: const password, isTemporaryPassword: false }): do {
      await setPassword(username, password);
      // Notice the use of the for loop at the end.
      // As the proposal currently stands, this is not legal.
      // (and yes, I know in this specific case a Promise.all()+array.map()
      // pattern would work better and would be legal, but you get the point).
      for (const email of getEmailsForUser(username)) {
        await sendEmailInformingOfPasswordChange(email);
      }
    }
    when (...) do { ... }
  }
}

Possible solutions:

Anyways, just hoping to start that discussion a bit, mostly because the pattern matching proposal seems to just assume that do blocks will work just fine for its use case, and do blocks, at the moment, seems to assume that they would never get used in a context like a "statement pattern match", i.e. there's a bit of a mismatch between these two proposals as they're currently defined.

ljharb commented 1 year ago

Pattern matching is an expression. Expression values can be ignored ("like a statement"), and this is fine and has no impact on anything.

Pattern matching will likely not be adjusted at all for do expressions; you'll just be able to put one in place of the RHS expression of a match clause.

I'm very confused what this is asking for.

theScottyJam commented 1 year ago

I think the updatePassword() example above illustrates the issue best.

As the do expression proposals currently stand, you can not write that example in its current form. It would be an error, because you're ending the do block with a for loop, and that's not allowed.

Having a for loop at the end of a block was forbidden due to the potentially bad assumption that a do block should never be used purely for side-effects purposes - if you're wanting side-effects and don't care about the completion value, you can just use a normal block instead - at least, that was the theory.

In practice, this assumption breaks with pattern matching, where a normal block isn't an option - if you want statements, you have to use a do block, which means you may resonably want to use do blocks purely for side-effect-only purposes (ignoring the completion value), which in turn means you may find it surprising that you can't end your do block with a for loop if you don't even care about the completion value.

You could do ugly hacks to get around the limitation (i.e. putting, say, a 0; after the for loop just to add another statement, so the last statement isn't a for loop anymore), but it would be unfortunate if we were actively encouraging hacks like that.

I am, however, realizing that pattern-matching isn't the only scenario that breaks this assumption. async do blocks will break the assumption as well - it would be very reasonable for someone to use an async do block purely for the side-effects, and not caring about the completion value, which means, again, someone may be surprised to find that they can't use a for loop at the end of the async IIFE, and may be forced to hack around the limitation, like this:

async do {
  for (const task of tasks) {
    await task();
  }
  0; // Putting this here simply because we can't end this do block with a for loop.
};
theScottyJam commented 1 year ago

So, I guess in a nutshell:

There are valid reasons someone may want to place a for loop, or if-without-else, etc, at the end of the do block - and that reason is that they're using the do block purely for side-effect purposes (they don't even care about the do block's final completion value). Because of this, we probably shouldn't ban these types of situations.

ljharb commented 1 year ago

A do block inside a match expression inside a for loop would not necessarily have the same syntax restrictions as a do block directly does. That's a problem for whichever is the second proposal to advance to solve.

As for the nutshell part, that there are valid reasons to do X, when doing X is a bug most of the time, is definitely an argument to ban X, especially when there's alternative ways to write it. Nobody has to use a do expression.

theScottyJam commented 1 year ago

A do block inside a match expression inside a for loop would not necessarily have the same syntax restrictions as a do block directly does. That's a problem for whichever is the second proposal to advance to solve.

It seems weird to give the do block different behaviors depending on where it is found, but I guess that works. Expect, it doesn't solve the async do block issue.

As for the nutshell part, that there are valid reasons to do X, when doing X is a bug most of the time, is definitely an argument to ban X, especially when there's alternative ways to write it. Nobody has to use a do expression.

The "alternative" for many wouldn't be to revert to an ugly IIFE, it's to circumvent the "helpful" restrictions by putting a dummy statement, like 0; at the end of the do block. And, of course I don't know for sure, but I suspect this would become a fairly common pattern if things continue the way they are.

"If you're using an async do block purely for side effect purposes, and you want to put certain control structures at the end, but the language won't let you, just remember to put a 0; afterwards. Maybe just get into the habit of always putting a 0; at the end of side-effect async do blocks so you don't have to worry about what kinds of control structures are and aren't allowed at the end of the block".

Perhaps we'll even get linter rules to helpfully remind us to add this 0; when it's necessary. Do we really want to be encouraging such a hacky-feeling behavior?

ljharb commented 1 year ago

I don’t think it would become common; but if it did the restriction could be loosened later.

What i think will happen is in the exceedingly rare case where someone is doing this, they’ll simply not use a do expression, no linter required.

theScottyJam commented 1 year ago

Out of curiosity, what exactly do you feel is "exceedingly rare"? And perhaps I'll focus on async do blocks here.

  1. The use of async do blocks in general? (I doubt this is it, but I'm putting it on here to be complete)
  2. Using async do blocks, and not caring about its completion value
  3. Using an async do block, not carrying about its completion value, and also wanting to use one of the forbidden control structures at the end of a do block?

And, just to be clear, when I talk about "not caring about its completion value", I'm talking about situations such as this:

function reader(size, callback) {
    if (promise) {
        async do {
            try {
                await prepare(await promise);
                reader.call(this, size, callback);
            } catch (error) {
                callback(error);
            }
        }

        return;
    }
  ...
}

This specific example I pulled from version 5.1.0 of the into-stream package, from their index.js file, but I converted their async IIFE to an async do block. This specific example only shows point 2, "using async do blocks and not carrying about its completion value" - it doesn't actually show what it would look like to also end the do block with a forbidden control structure. If it would help, I could look around for an example of that too.

ljharb commented 1 year ago

Using any kind of do expression (i'm not familiar with the term "do block") in one of the forbidden positions.

I don't think that conversion makes sense really - it should be:

function reader(size, callback) {
  if (promise) {
    return prepare(await promise).then(() => {
      reader.call(this, size, callback);
    }).catch(callback);
  }
}

and not need a do expression regardless.

55Cancri commented 1 year ago

Sorry if this was stated somewhere and I missed it but why does it have to be a do expression as in:

let x = do {
  return 2
}

I would think it should mimic Rust which is extremely terse and elegant:

let x = {
  2
}

Also more generally, it seems like features are starting to finally get added to js after what seemed like a few years of stagnation. Recently groupedBy, toSorted and friends, at, and a few others have made it to stage 3 are already implemented in browsers. When do you estimate do expressions (and pattern matching) would eventually make it to javascript (if it ever does)? 1-2 years? 5? Is there some kind of outstanding blocker or some kind of scrimmage like the pipeline operator with the f# and hack pipes? Any insight would be highly appreciated!

bakkot commented 1 year ago
let x = {
  y
}

is already legal, so that doesn't work.

There is no concrete blocker except time and interest from people working on it.

coolCucumber-cat commented 1 year ago

@55Cancri The thing you suggested means that you are creating an object with a key called "y" with the value of the variable of "y". The reason the "do" has to be there is becasue there's no way to tell it apart from an object. Also those two aren't the same, you don't have to use the return keyword, that would return from the current function, just like in Rust too.

coolCucumber-cat commented 1 year ago

The rules for do-expressions are assuming that you will use it as an expression, so if you don't use it an expression, then those rules are pointless. I'm not sure about Rust, but I remember in Java if you use a switch-case with the "->" instead of ":", it can be an expression or a statement, if it's statement it doesn't care but if it's an expression you have to provide a clear value for it to evaluate to. You can tell if it's an expression by if it needs a semicolon at the end. I assume it's the same in Rust because neither have objects like JavaScript (and because Java copied it from Rust).

It just kinda depends how you want to think of it, is it purely an expression or is it like an arrow function? And is "{}" also an expression and it just happens that objects have the same syntax and we need "do" as a marker to tell them apart or is it that "do {}" is a completely new thing?

I'm still in favour of an arrow function type style because:

Completely new unrelated solution: no ":" to use as a code block which evaluates to an expression, use a ":" like in the password example for it to be purely an expression. We could apply this to stuff to, for example, the top 3 evaluate to the string 'expression', the bottom 3 would evaluate to an object.

if (test) {'expression'}
when (test) {'expression'}
(test) => {'expression'}

if (test): {'object': 123}
when (test): {'object': 123}
(test) =>: {'object': 123}