proposal-do-expressions: Alternative proposal: Expression block

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?
}

About this issue

  • Original URL
  • State: open
  • Created 3 years ago
  • Reactions: 11
  • Comments: 42 (21 by maintainers)

Most upvoted comments

It seems like you could support a do shorthand where do <statement> desugars to do { <statement> }. Example:

do if (x === y) {
    4
} else {
    5
}

This gets you a shorthand for when you want to use statements in expression position.

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

That really isn’t much.

(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.

is it possible that at some point we would want to add expression forms of “if” and/or “try” (even if it ends up being a separate proposal)?

I think that if we get something like the current proposal we are unlikely to want to also add expression forms of if and try (if especially, given that we already have ternaries). So, while it’s possible, I don’t think it’s all that likely.

More importantly, I definitely don’t think we should design this proposal around a possible future extension. This proposal would be decidedly incomplete if it did not allow you to put try as the final statement, so either we should go with the current proposal or we would need to add those as part of (or prior to) this proposal.

If so, do we really think it’s best to allow some statements at the end of “do” and not others

Yes, that is my position. Though in any case, from a user’s point of view, that’s really not all that different from “allow only expressions, but also change some statements into expressions”.

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.

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.

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).

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.

Alternatively, we could run with what @pitaj suggested - I beleive there’s a whole issue dedicated to that sort of thing.

#1

OK - let’s try this idea on for size.

Currently, a do expression looks like this:

do {
  <statement>
  <statement>
  <statement that's capable of having expression semantics>
}

What if, we made a divide right down the middle of the semantics of do blocks, and split it into two separate things, each of which is simpler than the current do proposal, but together provide equal power.

do {
  <statement>
  <statement>
  <expression>
}

with <statement that's capable of having expression semantics>

In other words, if the concern is that we’re now introducing four+ syntactic forms, lets simplify what’s being proposed here so that we’re only proposing two syntactic forms. This would give us the power to do things like this:

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

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

const stuff = do {
  const x = 2
  const y = 3
  with if (x === y) {
    4
  } else {
    5
  }
}

function f(x = with return) {} // If you really want to, it's possible ...

See how this works? The “with” keyword can receive any expression-compatible statement afterward such as if (with else), return, yield, etc. The do block itself just expects an expression at the end. This is pretty similar to what was originally proposed, but now we’re only conceptually adding one new expression construct instead of many.

why not just add ReturnExpression?

I think there will be a lot less appetite for adding four+ syntactic forms, rather than just the one added in this proposal. Also, @ljharb has expressed strong opposition to that idea.

@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.

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.

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.

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 …

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.

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.