proposal-do-expressions icon indicating copy to clipboard operation
proposal-do-expressions copied to clipboard

Alternative proposal: Expression block

Open Jack-Works opened this issue 4 years ago • 43 comments

Thanks for the discussion started by @theScottyJam in https://github.com/theScottyJam/proposal-statements-as-expressions/ and https://es.discourse.group/t/statements-as-expressions/894. I'd like to propose my alternative design inspired by him.

Proposal(s)

Expr block

ExprExpression:

expr ExprBlock

ExprBlock:

{ ExpressionOrDeclarationList }

Early Error:

  1. It's an early error if the last item of ExpressionOrDeclarationList is Declaration.

If Expression

... the expression version of if statement, with the requirement of no missing else branch. We should create a new proposal for it.

Throw expression

https://github.com/tc39/proposal-throw-expressions

~~Switch expression~~ Pattern matching

https://github.com/tc39/proposal-pattern-matching

Try expression

... the expression version of a try statement, with the requirement of no finally branch. We should create a new proposal for it, or https://es.discourse.group/t/try-catch-oneliner/107

For loop

Use Array methods like forEach of map. For iterators, use https://github.com/tc39/proposal-iterator-helpers instead.

Return, continue, break

Don't do it.

Benefits

  1. The lookahead ∉ do can be removed since we're using expr as the keyword.
  2. Those syntaxes are much more composable, if expr, try expr can be used alone.
  3. Still allow most of the cases of the current proposal.
  4. We can get rid of EndsInIterationOrBareIfOrDeclaration. They are naturally banned on the syntax level.
  5. We can get rid of the var declaration inside a do-expression because the var declaration is a Statement that is not allowed.

Example

let x = expr {
  let tmp = f(); // yeah
  tmp * tmp + 1
};

let x = expr {
  if (foo()) { f() }
  else if (bar()) { g() }
  else { h() }
};

return (
  <nav>
    <Home />
    {
        if (loggedIn) {
          <LogoutButton />
        } else {
          <LoginButton />
      }
    }
  </nav>
)

Notice:

  1. in the JSX example above, we're don't need expr block because of if expressions.
  2. We can still cover the temporary variable case because Declaration is allowed (in the non-end position).

Early errors

expr {
  let x = 1;
};
expr {
  function f() {}
};

Declaration in the end. This is an early error.

expr {
  while (cond) {
    // do something
  }
};

expr {
  label: {
    let x = 1;
    break label;
  }
};

Syntax error: only expression or declaration is allowed.

expr {
  if (foo) {
    bar
  }
}

Syntax error: If-without-else is not a valid if expression.

Edge cases

var

Totally not legal. No hoist ✨

Empty expr {}

undefined.

await and yield

Inherit.

throw

We use the throw expressions proposal!

break, continue, return

No no no, things like this in an expression position are bad!

Conflict with do-while

We don't have this problem 🎉

B.3.3 function hoisting

Sloppy-mode function hoisting is not allowed to pass through a do-expression.

What do we miss?

Case: re-generator

Before:

const result = do {
    let r = 0
    for (const i of x) {
        r = yield r
    }
    r;
}

After:

const result = expr {
    yield* Iterator.from(x).reduce((sum, value) => value, 0)
    // is this example identical to the before?
}

Jack-Works avatar Aug 08 '21 05:08 Jack-Works

Extra benefit:

Once we have the concept of ExprBlock, we can use it in both the pattern matching proposals (without considering if we need a do keyword or not) and if expression/try expression.

Jack-Works avatar Aug 08 '21 05:08 Jack-Works

Another benefit:

Doing odd things like "return inside a function parameter list" won't be allowed anymore. The do-expression proposal currently doesn't have a good way to address some of the inconsistincies related to return.

// Current do-expression proposal
function f(x = do { return null }, y = 2) { ... } // Allowed
class { x = do { return } } // Dis-allowed

In general, the current do-expression proposal has a whole lot of "you can do this, unless ..." in it. This "expr" proposal seems to be a lot more self-consistent and predictable.

theScottyJam avatar Aug 08 '21 13:08 theScottyJam

Pattern matching relies on do expressions for the RHS of a match clause, and to be able to put a statement list there. This alternative proposal wouldn’t meet that use case.

ljharb avatar Aug 08 '21 14:08 ljharb

Pattern matching relies on do expressions for the RHS of a match clause, and to be able to put a statement list there. This alternative proposal wouldn’t meet that use case.

Can you address what cases cannot be expressed in this way? In this thread I have the concept of "expression block" that can be used as the RHS of match clause.

Jack-Works avatar Aug 08 '21 14:08 Jack-Works

Right, but if we have "expression block", why would we need the individual statement-expression forms? The "expression block" parts just seems like the same thing as do expressions, but with restrictions that were already rejected in plenary (delegates unfortunately REQUIRE the ability to return, eg, from expression position)

ljharb avatar Aug 08 '21 14:08 ljharb

Well, I know earlier formulations of pattern matching toyed around with the idea of having both an expression and statement version of the match construct. This was thrown out in favor of just using do expressions in the match construct. But, who knew at the time how many nasty gotchya's do expressions would have. Maybe it's worth revisiting that original idea - have a statement version of pattern-matching for imperitve programming needs, and an expression form for functional needs, instead of trying to come up with a way to allow imperative programming in expression positions so that we only have one "match" construct. (I know having a statement and expression version of pattern matching isn't the prettiest thing, but it's arguably nicer and more intuitive than the do expression proposal as it currently stands).

theScottyJam avatar Aug 08 '21 14:08 theScottyJam

Right, but if we have "expression block", why would we need the individual statement-expression forms?

Because that's taking one of the most useful features of do-expressions and putting it in the spotlight. I've seen a countless number of people on this proposal asking for this kind of thing - they're just wanting to use "if" or "try" in an expression position, without the extra noise that "do { ... }" creates. If you have the ability to use those constructs in an expression position, then really, the only thing left to allow people to use Javascript in an entirely expression-oriented fashion is some way to create declarations in an expression position. This expr block gives precisely that, and nothing more. There's other syntax ideas out there that could also do the same thing (e.g. many functional languages use a "let x = 2, y = 3 in x + y" sort of syntax to accomplish this - it's really exactly the same as the proposed expr block, but different syntax).

Another thing, is that the expression block purposely does not give you power to use statement versions of "if" or "try" to keep things simple and intuitive. The last line of an expression block has to simply be an expression. That's it. The do expression proposal says the last line of a do block is any statement, except if without else, and for loops, and while loops, and ....

theScottyJam avatar Aug 08 '21 14:08 theScottyJam

@theScottyJam none of those gotchas i consider a problem. Do expressions are the only way pattern matching can be an expression.

The most useful feature of do expressions is "turning a statement list into an expression", which this alternative does not do.

ljharb avatar Aug 08 '21 14:08 ljharb

The most useful feature of do expressions is "turning a statement list into an expression", which this alternative does not do.

You're right, this alternative instead turns "statements into expressions". Many languages, such as Elm, Haskell, etc don't even have a concept of statements. Everything inside a function body must be an expression. Once that's done, there's no need for a feature that turns a statement list into an expression.

(btw, if you want something like return to be an expression, we could simply make that happen, like we're doing with "throw". I wouldn't want it that way, but it could still be a possible conversation. That's the point of this all, whatever we think should be an expression, let's make it happen. Everything else should not).

Do expressions are the only way pattern matching can be an expression.

Here's how pattern matching can be done in an expression position (this will be similar to many other languages that do pattern matching):

const result = match (data) {
  when ({ status: 200 }) expr {
    const x = 2
    const y = 3
    x + y
  }
  else (
    if (someCondition) resultA()
    else if (anotherCondition) resultB()
    else resultC()
  )
}

In other words, after the "when" you require an expression. Because we've turned any useful statement into an expression, you should be able to do anything you need to in the body of the match arm.

@theScottyJam none of those gotchas i consider a problem.

They're not deal breakers, but they certainly increase the learning curve of do expressions.


With all of this said, I do agree with you that it would be a loss to not have do expressions be the body of a pattern matching arm (unless you're using a statement version of pattern matching). But, I feel like a proposal like this expr block one does a better job at providing a more general-purpose and easier-to-learn solution. In other words, if pattern matching is the only thing that's keeping do expressions in its current shape, then perhaps we haven't found a general-purpose-enough formulation for it yet.

theScottyJam avatar Aug 08 '21 15:08 theScottyJam

The most useful feature of do expressions is "turning a statement list into an expression", which this alternative does not do.

"Turning a statement list into an expression" is the way of approaching the final target, but it is not the target itself. What we really want is to have enough expressiveness in the expression position. Can you give an example about what cannot be expressed without a statement once we have try, throw, if expression, and pattern matching?

but with restrictions that were already rejected in plenary (delegates, unfortunately, REQUIRE the ability to return, eg, from expression position)

As @theScottyJam said, we can make return into an expression if it's really necessary. (In the current version do { return expr } has the same effect).

Jack-Works avatar Aug 08 '21 15:08 Jack-Works

Almost every single statement would need to be made into an expression, at which point, we're adding multiple new forms, versus the single do around a StatementList (modulo any minor restrictions it has that nobody will ever actually run into or need to learn in a practical sense).

I think this alternative proposal is exceedingly more complex and confusing than the current one.

ljharb avatar Aug 08 '21 15:08 ljharb

Almost every single statement would need to be made into an expression

That really isn't much.

  • BlockStatement => Expr Block expr { ... }
  • IfStatement => if expression
  • ReturnStatement => Return Expression (if it is important)
  • ThrowStatement => https://github.com/tc39/proposal-throw-expressions
  • TryStatement => https://es.discourse.group/t/try-catch-oneliner/107
  • DebuggerStatement => https://github.com/tc39/proposal-standardized-debug
  • SwitchStatement => https://github.com/tc39/proposal-pattern-matching
  • VariableStatement(var)/IterationStatement/ContinueStatement/BreakStatement/WithStatement/LabelledStatement => No expression form and I think it's bad to have an expression form for it.

(Iterations? Recursive functions/iterator helpers/Array.* methods can do that!)

That's almost all statements we have. Once we having this in the language, we can do really powerful things with expressions

any minor restrictions it has that nobody will ever actually run into or need to learn in a practical sense

I really doubt this. I think it will be very common for if to be appeared at the end of the do expresion. How can it be a edge case?

And e.g.: if-expression has a clear rule: You must have else branch. But the current early error check is very awkward: You must have else branch when you inside a do expression, and when it is the last statement (and you need to check it in the nested way).

if-expression has a more clear rule and it's easier to teach.

Jack-Works avatar Aug 08 '21 15:08 Jack-Works

For an elseless if to appear at the end? I don’t think so. I think the most common will be ending with an if/else, so it can produce a const without a ternary (or without a let).

ljharb avatar Aug 08 '21 15:08 ljharb

And don't you think splitting them make them much more composable? You can use the necessary syntax without the extra do {} when it doesn't contain Declarations.

const a = do { if (expr) expr1; else expr2 }
const b = if (expr) expr1; else expr2 // syntax not important here

Jack-Works avatar Aug 08 '21 16:08 Jack-Works

For an elseless if to appear at the end? I don’t think so.

What about this scenario? I'm sure people will try to do this:

match (data) {
  when ({ x }) {
    console.log('value was', x)
    if (x > 0) console.log('it was positive')
  }
  else {
    console.log('unknown value')
  }
}

If pattern-matching is intended to also be used in a statement position to enable procedural code, then people will try to do procedural logic within the branch arms of pattern matching, and find they must place a dummy "undefined" or something at the end of the implicit do block, just to make it work. This is another argument in favor of splitting pattern matching into both a statement in expression form.

theScottyJam avatar Aug 08 '21 16:08 theScottyJam

No, i don’t. I also think that this alternative is much harder to teach about “converting code back and forth”. The current proposal is “wrap it on do { } / remove the wrapper”, modulo syntax errors in the wrapped form. This one requires diving into each statement and making a different kind of conversion, in either direction.

@theScottyJam if they do, and they get a syntax error, then they won’t, problem solved.

We could also potentially remove those restrictions from the RHS of pattern matching (and even do expressions) when it’s in statement position, since when it’s in expression position, i think it’s clear nobody would do that in the first place, since they’d want the match to result in a value.

ljharb avatar Aug 08 '21 16:08 ljharb

We could also potentially remove those restrictions from the RHS of pattern matching (and even do expressions) when it’s in statement position, since when it’s in expression position, i think it’s clear nobody would do that in the first place, since they’d want the match to result in a value.

If you do that, then you're pretty much got a statement and expression for of pattern matching. "match" in a statement position uses regular blocks on the RHS, while in the expression position it uses whatever expression block we decide to go with on the RHS (be it do blocks, this expr block proposal, etc).

Alright, so lets follow this line of reasoning:

Why should we provide an expression variant of pattern matching at all? Well, you've got to agree with me that it's pretty useful to be able to do things like the following:

const fn = x => match (x) {
  ...
}

const result = match (data) { ... }

// etc

Alright. So, what if we copy-paste the arguments for having an expression form of pattern matching, and apply them with other syntax constructs, such as "if" and "try/catch"?

const result = try { ... } catch (err) { ... } // This would be really nice to have

const numb = (
  // So would this
  if (...) { ... }
  else if ( ... ) { ... }
  else { ... }
)

If we find pattern-matching valuable in an expression position, shouldn't we also find "if" and "try/catch" also valuable as expressions? And we can do it by following the exact same behavior we're doing for pattern matching. If "if" is in a statement position, we use statement blocks, and if it's in an expression position, we use whatever form of "expression" blocks we come up with. I don't see how we can argue for pattern matching to be an expression construct, without arguing the same for "if" and "try", unless there isn't a consistent way to do so.

Alright, now the final question is - what should these expression blocks look like? Well, we can continue with the do expression proposal, or, we could make things simpler, knowing that we're now able to use the most important constructs in ~~a statement~~ an expression position, and don't really need to have a way of turning statements into expressions inside the chosen expression block. Whether or not the "expr block" proposal really is simpler to understand is certainly arguable, but I feel like there's less rules involved with it, so it's quicker to learn - yes any mistakes with the do block would become syntax errors and not runtime errors, but I would still rather have syntax constructs where it's easy to apply them without being unsure until you run the code whether or not you've made a syntax error.

theScottyJam avatar Aug 08 '21 16:08 theScottyJam

Here's another scenario I thought of, where someone may try to use a do block incorrectly, and run into these issues:

async do {
  const x = f()
  if (x) console.log('whatever')
}

Even in a code snippet like the following:

const x = do {
  f()
  if (y) 'z'
}

I would guess that if y were truthy, then x would be equal to 'z', otherwise, it would be equal to undefined. I wouldn't expect that to be a syntax error. I would have to of had learned about how each statement operates when placed at the end of the do block, and learned about these special rules to know that that wouldn't be valid syntax. I think it's simpler to, at the very least, only allow expressions at the end of a do block. if, try-catch, etc can be turned into expressions using the line of argument from my previous comment.

theScottyJam avatar Aug 08 '21 17:08 theScottyJam

@theScottyJam yes, if we did that we'd have two forms, but it'd be pretty easy to control it in the spec with a grammar flag, i think.

The separation between statements and expressions is imo a good and necessary thing, and I think muddying the waters by making more statements implicitly into expressions would be a very bad idea.

In your latter code snippet, another perfectly reasonable intuition is that if y is falsy, x is whatever f() is.

ljharb avatar Aug 08 '21 18:08 ljharb

yes, if we did that we'd have two forms, but it'd be pretty easy to control it in the spec with a grammar flag, i think.

Wouldn't this also be true for "if" and "try/catch"? We would just need a grammar flag to control how it behaves when in a statement or expression position? With both "if" and "match, when they're in statement positions, their bodies can behave like block scopes, and when they're in an expression location, we can use whatever grammar flag, and make their bodies behave like whatever form of expression body we go with.

The separation between statements and expressions is imo a good and necessary thing, and I think muddying the waters by making more statements implicitly into expressions would be a very bad idea.

This also sounds like an argument for making pattern-matching a statement-only construct. Doesn't having pattern-matching behave differently in statement vs expression positions muddy the water just as much as it would with "if" or "try"?

I do agree that it's a good idea to try and keep a clear distinction between statements and expressions - for this reason, in earlier revisions of this proposal, we were using "with if" and "with try" for the expression forms, so they could conceptually be thought of as "slightly different constructs" rather than "the same construct in an expression location". I would similarly argue that with pattern matching, it might be good to make the statement form just be "match", and the expression form be something like "with match", so as to not muddy the waters, and make it clear why the expression form has minor differences in behaviors from the statement form.

theScottyJam avatar Aug 08 '21 20:08 theScottyJam

@theScottyJam yes, but unchanged, if and try etc are not viable to suddenly become expressions. That would not achieve consensus.

A new construct doing this, however, would not have the confusion of suddenly having different semantics after decades.

ljharb avatar Aug 08 '21 21:08 ljharb

@ljharb As we see, many js programmers want if expression, so i'm not sure what's the real blocker issue of if expression?

hax avatar Aug 08 '21 22:08 hax

Many people want many things; that's a nice precondition but not an automatic qualifier. I think it would be confusing, and would cause many bugs, and i don't want any statements to directly become expressions - only inside an explicit wrapper, like do { } or expr { } (or any other acceptable spelling).

ljharb avatar Aug 08 '21 22:08 ljharb

I thought about this case, it's awkward:

const val = expr {
    if (val2) console.warn('what')
    // syntax error, expected token "else"
    val2 + 1
}

I think it's simpler to, at the very least, only allow expressions at the end of a do block. if, try-catch, etc can be turned into expressions using the line of argument from my previous comment.

I agree. If we change the expression block to { StatementOrDeclaration Expression } and have expression version of try, if and throw, we will have the same semantics of do expression currently. And it will be a nature result to have the same semantics of EndsInIterationOrBareIfOrDeclaration does, in extra, we can have try and if useful on it's own.

Jack-Works avatar Aug 09 '21 02:08 Jack-Works

yes, but unchanged, if and try etc are not viable to suddenly become expressions. That would not achieve consensus.

Sorry, maybe I'm just slow, but the arguments don't seem to be lining up in my head...

By unchanged, are you referring to the fact that we're changing their behavior by, in the case of "if", dis-allowing if without else when used as an expression? If so:

  • You seemed to have zero worry about if-without-else when in a do block, because "if [someone does that], and they get a syntax error, then they won’t, problem solved". How come this is suddenly a worry when "if" is an expression? You argued that do blocks are simple because they're just a "single do around a StatementList (modulo any minor restrictions [do blocks have] that nobody will ever actually run into or need to learn in a practical sense)" - shouldn't we be able to apply the same argument here? If without else is a minor restriction that nobody will ever actually run into or need to learn in a practical sense? (I don't actually agree with this argument, as I've previously expressed, but you seem to, so I see no reason why I can't reuse it 🤪️)
  • You also seem to not worry about having "match" in a statement position use block scope, and in an expression position use a do block. Why is this behavior change allowed but not an if-without-else behavior change? Is the user likely to notice one and not the other?
  • What if we did allow if without else in an expression position? (if the if condition fails, then the completion value would be undefined). Now the only behavior change between statement and expression versions of "if" would be the same kind of behavior change you're comfortable seeing in pattern-matching.

theScottyJam avatar Aug 09 '21 18:08 theScottyJam

I'm pretty sure the change he was referring to was "if..else can now be used in expression position"

pitaj avatar Aug 09 '21 18:08 pitaj

@theScottyJam what i mean is, const x = if.expression (…) { … }; would be a change, const x = if (…) { … }; would be unchanged syntax. The unchanged one is the nonstarter for me.

In a new construct, there can't possibly be anything someone "knows" will work or fail, only expectations. I'm completely comfortable with a new construct, like class or async function or Modules or match having new rules, as long as they're easy to figure out and hard to silently do the wrong thing.

"expression position" isn't a new construct, and i don't think it's appropriate for anything to suddenly become eligible to be there that wasn't before. I feel the same about the throw expressions proposal, but decided not to obstruct it since indications were that it was going to be the only statement converted into an expression, with do expressions handling the rest.

ljharb avatar Aug 09 '21 21:08 ljharb

Ah, gotcha

So how do you feel about other syntax changes to the if construct? e.g. I've seen the idea get thrown around of allowing if (const x = f()) { ... } behavior - would that also be a non-starter because it's adding new behaviors to if?

Either way, we do both seem to agree that if expression-if would exist, it should have different syntax, to distinguish it from statement-if. We just disagree on the reasons why 😁️. We'll just assume that we're pushing a "with if"/"with try" thing for now (exact syntax can be bike-shedded later). And, let's run with what @jack-works said and keep do expressions as they are, but changing it so the last line only accepts expressions, not any statement.

So now, the question is, which is easier to understand and more useful? Do expressions as they currently stand? Or, only-expressions-at-the-end + "with if" + "with try"?


Aside: My personal vote is that we stop it with the implicit returns, and go for a completion-value-marker keyword such as give 2 + 2 (similar to a "return" but for the block), as discussed in #55. This removes the special treatment for the last line of the do block, the last line can just be a regular statement, an if without else, etc. This also removes the need for "with if" and "with try", because you can just use a statement "if" instead, and end each branch with this completion-value-marker, the same way you currently use "return". I would still hope for a "with if"/"with try", but it would have to be a different proposal if this route is taken, because do blocks wouldn't have any need for it

theScottyJam avatar Aug 09 '21 22:08 theScottyJam

It seems very strange to me to restrict what items can appear prior to the final statement in the list - significantly stranger than the current proposal's limitations, which I don't think people are likely to run into in practice (except for the unfortunate if one). Since items other than the final statement are obviously not used for their result value, there's no reason to disallow loops or try-statements or anything else.

For a concrete example, it really seems like you ought to be able to write code along the lines of

const val = expr {
  let sum = 0;
  for (let i = 0; i < 10; ++i) {
    sum += f(i);
  }
  sum;
};

I think your proposal would not be viable if it forbids the above code.

So let's say you remove that restriction, and say that you can put any statement before the last line, there being no reason to restrict those. At that point you have exactly this proposal, except that

  • var is forbidden, which seems like a completely unrelated change
  • you can't put an if or a try on the last line, which seems like an unfortunate limitation
  • you can't use break/return/continue, which I had initially excluded but added in at the explicit request of the committee - the proposal cannot advance without allowing those.

Each of those things is something we could discuss independently of the others.

bakkot avatar Aug 09 '21 22:08 bakkot

@bakkot - I also find the original expr {} construct a little weird for that reason. There are other ways to formulate the syntax (which were discussed in the TC39 form post), such that it looks less like a standard block, thus making people less likely to think that they can throw a for loop in the middle of it.

As mentioned previously, many other languages use a let x = 2, y = 3 in x + y type of syntax to accomplish similar objectives to the do block proposal, which really is the exact same thing as the expr block, but with different, arguably more intuitive syntax. I, personally, think a syntax like this would work great in Javascript:

const groups = (
  with user = await getUser()
  with groups = user.groups
  with allRoles = await getAllRoles()
  do pickRolesFrom(allRoles, { roles }) // groups will be assigned to the value of this final expression
)

The general syntax for this is: one more more "with" declarations, followed by a "do", then an expression. Note that this is really the same as expr {}, just with different syntax. All of the preceding lines must still be declarations, and the last line must still be an expression.

I like this sort of syntax construct, as it forces all imperitive-ness out from the expression. At a glance, you know that many surprised won't be found within the with-do construct (at least, the syntax naturally discourages it, people can still find ways around it). For example, you're unlikely to find code modifying object, local variables, or globals hiding in the middle of this all (e.g. it's awkward to use obj.x = 2 in the middle of this syntax construct). People are less likely to be calling state-modifying functions, because the expression-nature of this construct forces users to always make use of whatever gets returned (e.g. it's awkward to use map.clear(), because you're forced to do something with the return value, and its return value is pretty useless). In short, it provides some really nice (pseudo) guarantees, that makes it really easy to tell what a chunk of code does at a glance, without hidden surprises, and it encourages good coding practices.

I do, however, understand the desire to have imperative programming in an expression position. But, it seems like a shame to pass up an opportunity to provide a syntax construct as powerful as this one.

theScottyJam avatar Aug 09 '21 23:08 theScottyJam