tc39 / proposal-do-expressions

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

How to return literals? #55

Open bakkot opened 3 years ago

bakkot commented 3 years ago

This issue was posted by @getify on my fork of the repo, which is what I presented to the committee. I'd like to centralize the discussion here, so I'm reposting it, though see comments in that thread. Original issue follows:


The explainer shows returning the result of function calls (by simply calling them and relying on the completion value), as well as an expression (tmp * tmp + 1). But I'm wondering about the ergonomics of returning literal expressions, like strings, numbers, booleans, arrays, and (especially) objects.

let fancyJobTitle = do {
   let title = getBoringJobTitle() || "Intern";
   `Sr. Rockstar ${title.toLowerCase()}`;
}

I assume that works. But it's not particularly readable/obvious that the string hanging out there is the result of the do. Similarly:

let meaningOfLife = do {
   let answer = askComputer();
   if (answer !== undefined) answer;
   else false;
}

It doesn't seem terribly obvious that false might come out here. And also, implied undefined could be confusing here:

let meaningOfLife = do {
   let answer = askComputer();
   if (answer !== undefined) answer;
}

Even more confusing to me is how do you express the returning of an object literal from such an expression. I assume we have to wrap in parens?

let status = { step: 3 };
status = do {
   let { step = 1, phase = "beta", milestone = "q2" } = status;
   ({ step, phase, milestone });
}

(I think that one in particular could introduce an ASI hazard if the preceding statement doesn't have a terminating ;, because then the ({ .. }) looks and is treated like a function call even though it's on the next line.)


To address these concerns, I have a soft proposal idea I wanted to float... that you should be allowed (but not required) to use an additional keyword token to disambiguate (and make more readable/obvious) the literal/expression you're intending to "return".

I was thinking something like metaprop keyword style:

So for example:

let status = { step: 3 };
status = do {
   let { step = 1, phase = "beta", milestone = "q2" } = status;
   do.yield { step, phase, milestone };
}

There we remove the need for the parens, we eliminate the ASI hazard from the previous line (though this new keyword would have its own ASI requirement), and we make it more obvious in places where it seems a little hidden/unobvious/ambiguous.

let meaningOfLife = do {
   let answer = askComputer();
   if (answer !== undefined) return.do answer;
   else return.do false;
}

By making this keyword optional instead of required, it reduces the burden of the feature in cases where the keyword wouldn't be as helpful, but allows it in places where it is. It's sorta like how you can choose between either of these styles of arrow-functions according to your stylistic preferences:

const fn1 = (x,y) => ({ x, y });
const fn2 = (x,y) => { return { x, y }; };

The choice to give arrow functions a "full non-concise body" option was a big one... which added a bit of complexity and cognitive overhead. I used to lament that, but 6 years on, I think that choice was a key design "feature" here, because it's made arrow functions more broadly applicable.

I think the same could be true of this optional keyword idea inside do expressions.

pitaj commented 3 years ago

Why can't I return an object literal directly like so?

let x = do {
  { a: 1, b: 2 }
};
bakkot commented 3 years ago

Because in statement contexts - which would include the body of a do - { begins a block, not an object literal.

pitaj commented 3 years ago

Ah I suppose that makes sense. Is an object literal the only case like this?

bakkot commented 3 years ago

Function and class declarations are the other example; you can't have either as the final statement of a do, since that would not have the semantics you'd expect.

That particular restriction is pretty unfortunate and I'd like to fix it, but there's some web-compat risk (since my preferred fix would change the semantics of eval).

(do expressions themselves would be another example, but that's kind of pathological.)

getify commented 3 years ago

Presumably though, we could return a function or class expression by wrapping it in parens, right? Of course, opening parens on the line has the ASI hazard I mentioned for object literal return. :/

Another use-case I would love to handle with do-expressions is "creating private closure for a function":

const pointDist = do {
   let memoizationCache = {};
   (function pointDist(x,y){
      let key = `${x},${y}`;
      if (key in memoizationCache) return memoizationCache[key];
      // ... compute and cache point-distance
   });
} 

In the case of "returning" a function expression by wrapping it in parens, that even less "looks like" a return because devs have for so many years been conditioned to opening paren before a function keyword as an indication of an IIFE.

I would say that begs (even more than the object literal case) for an optional keyword to make it clearer:

const pointDist = do {
   let memoizationCache = {};
   do.return function pointDist(x,y){
      let key = `${x},${y}`;
      if (key in memoizationCache) return memoizationCache[key];
      // ... compute and cache point-distance
   });
} 

That to me reads significantly clearer.

getify commented 3 years ago

do expressions themselves would be another example, but that's kind of pathological.

Why can't you do this?

let x = do {
   let y = 10;
   do {
      y * 3;
   } 
}

Is it because the inner do looks like a do..while loop (statement)?

Jack-Works commented 3 years ago

do expressions themselves would be another example, but that's kind of pathological.

Why can't you do this?

let x = do {
   let y = 10;
   do {
      y * 3;
   } 
}

Is it because the inner do looks like a do..while loop (statement)?

If you look at the spec, you will find the do at the start of the statement will not be parsed as do expression but do statement. You need to do this:

(do {
    y * 3
})
bakkot commented 3 years ago

Is it because the inner do looks like a do..while loop (statement)?

Yup.

That said, there's not much use for a do expression in statement position, since (do { ... }) should be equivalent to { ... }.

bakkot commented 3 years ago

In the case of "returning" a function expression by wrapping it in parens, that even less "looks like" a return because devs have for so many years been conditioned to opening paren before a function keyword as an indication of an IIFE.

Yeah, like I said I think this case is particularly unfortunate and I'd like to fix it so a bare declaration just does the right thing.

pitaj commented 3 years ago

If you're returning a closure, why not just use an arrow function?

const pointDist = do {
   let memoizationCache = {};
   (x,y) => {
      let key = `${x},${y}`;
      if (key in memoizationCache) return memoizationCache[key];
      // ... compute and cache point-distance
   }
} 
getify commented 3 years ago

why not just use an arrow function?

That's basically the same question as "why not just always use arrow functions?" There's a variety of reasons to choose non-arrow functions: wanting access to arguments, wanting to be able to adopt a dynamic this binding, wanting a lexical name for self-reference (recursion), etc.

pitaj commented 3 years ago

Dynamic this binding is the only real application there. ...args replaces arguments, and you can use a variable in place of a lexical name. Seems like a pretty rare application, so I don't really think it's worth much consideration.

hax commented 3 years ago

I really support explicit keyword for local return of do expression. This also solve the trouble issue of refactoring IIFE with "early return" or "guard pattern".

The only question is what syntax. "foo.bar" is like property so we only use it for meta-property before. (Note await.ops proposal also have same issue.)

Maybe do return V could be an option. Another option could be break with V. I prefer break with, because return or yield could always be confused with non-local return and generator.

theScottyJam commented 3 years ago

Another place where an explicit "give-back" keyword would be useful is with function calls.

do {
  f()
  g()
  h()
}

...oh right, the return value of h() is being returned from the do block, it's not just another side-effect function like f() and g(). An explicit "give-back" keyword takes out some of the cognitive overhead of remembering that the last statement is special, even though it doesn't look special.

Within a "do" block, wouldn't we be free to introduce new keywords that would normally be valid identifiers? Similar to how "await" is only a keyword inside an async function? My suggestion would be the keyword "give" - it's simple to remember and unique. But really, any keyword is better than no keyword.

do {
  f()
  g()
  give h()
}
theScottyJam commented 3 years ago

I'm going to also throw this idea out there. I expect I'll get a number of haters, but it's worth the discussion.

What if we made this "give-back" keyword mandatory instead of optional? Some benefits:

Some arguments against it:

Alright, now come at me with all of your opposing arguments :)

ljharb commented 3 years ago

What syntax and ASI issues?

theScottyJam commented 3 years ago

A lot of the previous comments in this thread have been mentioning different ASI hazards that developers may find themselves running into more frequently due to requiring parentheses around the last statement, to force it to be interpreted as an expression. If you wanted to give back functions, object literals, a sub-do block (not sure if that's useful), etc, then all of these currently require adding parentheses around the last expression, forcing a line to start with a parenthesis, and creating an easy ASI hazard for developers to trip up on.

By "ASI issue" I was referring to the fact that, as the proposal currently stands, we're encouraging lines to starts with parentheses. By "Syntax issue" I was referring to the fact that, as the proposal currently stands, a number of "give-back" values have to be wrapped in parentheses in order to be interpreted correctly. (Sorry - should have been a little more clear)

@bakkot mentioned that they want to fix these issues. I'm not sure yet what this fix would look like, and if it would introduce some language inconsistencies, etc - it'll be interesting to know once that fix is prepared. If that fix is simple and intuitive, then this part of the argument becomes void. If it's complicated, unintuitive, makes breaking changes, or creates inconsistencies with other parts of the language, then this part of the argument becomes more attractive.

ljharb commented 3 years ago

Ah - I don't consider that an ASI issue, since it's got nothing to do with semicolon placement. Thanks for clarifying.

getify commented 3 years ago

@ljharb sorry, I'm a bit confused by your response there.

It sure seems to me like "ASI", but perhaps I'm missing something as well.

let obj = do {
   let x = 2
   let y = x * 3
   x = something(x,y)
   ({ x, y })
}

That last line has an ASI hazard (right?), because the previous line ends in a parens, and then it starts with a parens. The proposal (without any contemplated keyword) is asserting that to return the object literal on the last line, the parens are needed, which does in fact incur the ASI hazard if you're someone who doesn't like to use semicolons. Right?

It's not a new ASI hazard related to the do expression itself, it's pre-existing in that sense, but it's an ASI hazard nonetheless, no?

ljharb commented 3 years ago

@getify ahhh thanks, that was the part i was missing. Yes, that's a pre-existing ASI hazard (thus, one linters already presumably account for)

theScottyJam commented 3 years ago

Yes, anyone coding with a linter should never run into ASI hazards like that, whether or not they choose to use semicolons.

But, I certainly am not coding with a linter 100% of the time, and many people don't use them when they're first learning Javascript (which are the people who are most likely to struggle with ASI), which is why it's useful to keep potential ASI hazards like this down to a minimum (at least, as much as it's reasonably possible).

I'm also sure that the semicolon hater party doesn't want yet another place that they need to prefix parentheses with semicolons :).

pitaj commented 3 years ago

I'm fine with an optional keyword to denote the completion value: I just won't use it. But I strongly oppose requiring the keyword.

The lack of return is one of the main reasons do blocks are useful instead of IIFEs. If you require a keyword then do blocks have far less value. I don't think people who avoid semicolons should be catered to and I don't think avoiding ASI hazards should be prioritized over a feature as fantastic as implicit return.

Implicit return is the reason I support do blocks, and I'm not alone in that.

theScottyJam commented 3 years ago

I find that do-expressions have a number of nice use-cases that are unrelated to implicit return (and IIFEs are subpar at handling them). But, I'm intrigued - why is implicit return "The reason" for you (and others)? Could you elaborate? What's an example code snippet where you think an explicit completion-value would ruin the usefulness of the do-expression? I think a good thoughtful discussion on this can help us get to the bottom of what everyone's needs are, so we can better find common ground.

I don't think people who avoid semicolons should be catered to

This isn't about catering to them. ASI affects semicolon users too. Not that long ago, my friend, a semicolon user, hit a nasty bug where he forgot to put a semicolon after const fn = function() { ... } (an easy place to forget it), and he had an IIFE afterwards to run some async logic (top-level await wasn't an option). This exact type of issue can happen within a do-block.

do {
  const fn = function () { ... } // oops, forgot a semicolon
  ({ fn }) // What's going on??
}

Maybe he should have been using a linter, but also, we can't just design a language that expects everyone to use a linter all the time to avoid nasty pitfalls.

Don't get me wrong - I'm not against implicit returns just because of ASI hazards (this might get fixed in future versions of this proposal anyways), nor am I 100% against the idea of implicit returns in general, but I do think ASI hazards are an important consideration when weighing all of the pros and cons.

ljharb commented 3 years ago

@theScottyJam that friend might soon be able to use an async do expression, and there'd continue to be no need for IIFEs :-)

liamnewmarch commented 3 years ago

I like the idea of being able to explicitly return from an expression block, however I feel like this overcomplicates do-expressions?

Given the established relationship between return and function (and =>), I wonder if it would makes sense propose an enhancement to existing function-expressions with a syntax that mirrors do-expressions? I have no idea if this is viable but I think the following could be a clean and readable alternative syntax for anonymous IIFEs.

How return is handled in the existing do-expressions proposal

function fn() {
  const exprValue = do {
    return 1; // returns enclosing fn
  };
  return exprValue + 1; // dead code
}

fn(); // 1

How return could be handled in suggested compact function-expressions

function fn() {
  const exprValue = function {
    return 1; // returns the compact IIFE block
  };
  return exprValue + 1; // executed as normal
}

fn(); // 2

Again, no idea if any of this is viable. One caveat that comes to mind compared to IIFEs is that the function expression block cannot take arguments. Another is that it breaks the mental model that code written with function can be called (). However I do like that this could also eventually incorporate existing function semantics that developers are already familiar with – for example creating generator or async functions:

Compact generator function expressions

const g = function* {
  yield 1;
  yield 2;
};

g; // Generator {<suspended>}
Array.from(g); // Array [1, 2]

Compact async function expressions

const p = async function {
  await someAsyncFn();
  return 3;
};

p; // Promise<3>
theScottyJam commented 3 years ago

@liamnewmarch - there have been other threads discussing the idea of scaling back the do expression to just be a more standard and less confusing/hacky syntax for IIFEs - see here and here. There's some really great discussion in those threads - feel free to add your thoughts to those discussions too. I'm all for these types of ideas - IIFEs aren't very readable, and having a more normal syntax would make them much more user-friendly.

However, do expressions offer other features that an IIFE can't provide - like being able to conditionally await for something (see this post to understand why this is useful, and why async IIFEs aren't as powerful here). do blocks would also allow performing flow control in the containing function, like break, continue, yield, return, etc - I'm not yet sure how useful I would find this feature yet, it could be invaluable, or it could be something that doesn't get touched much.

In my eyes, I see three possible ways this do-block thing can pan out:

theScottyJam commented 3 years ago

To play devel's advocate against my own idea (required completion value markers), and to try and understand why @pitaj might prefer not to use completion value markers, I can think of one scenario where the markers can be cumbersome, which I'll borrow from this post.

// compare this:
const val = do {
  if (count === 0) "none";
  else if (count === 1) "one";
  else if (count === 2) "a couple"; 
  else if (count <= 5) "a few";
  else "many";
}

// with this
const val = do {
  if (count === 0) give "none";
  if (count === 1) give "one";
  if (count === 2) give "a couple"; 
  if (count <= 5) give "a few";
  give "many";
}

The repeated "give" can seem a little cumbersome in this expression (not enough for me to really care, but I can see others disliking it). I will note that that a third alternative exists, if your style guide allows it. (I know some people like to use ternaries this way, and others are very opposed to it. It's just a matter of preference I guess)

const val =
  count === 0 ? "none" :
  count === 1 ? "one" :
  count === 2 ? "a couple" :
  count <= 5 ? "a few" :
  "many"

So, that's one place where implicit completion values are a little nice, but for those who are allowed to use this mutli-line ternary, it doesn't provide much benefit. Are there any other examples where implicit completion values are nicer than explicit ones?

getify commented 3 years ago

Since we're bringing the "devil" into this... ;-)

const val = match(count) {
   when 0 "none"
   when 1 "one"
   when 2 "a couple"
   when ^(count <= 5) "a few"
   else "many"
}

Since the RHS of when clauses is itself a do expression (when wrapped in { .. }), I think we could compare that version to:

const val = match(count) {
   when 0 { give "none" }
   when 1 { give "one" }
   when 2 { give "a couple" }
   when ^(count <= 5) { give "a few" }
   else { give "many" }
}
liamnewmarch commented 3 years ago

Thanks for the context @theScottyJam. I had seen those discussions and thought I was making a good point but think it got lost in the excitement of new syntax :)

However, do expressions offer other features that an IIFE can't provide

Absolutely, I agree. I think what’s nice about do-expressions is they don’t break things like control flow. What I was (clumsily) trying to suggest was that maybe we could keep do-expressions simple by moving function-like considerations (like return, await, generators, etc) to another proposal that’s more aligned with IIFEs. That way do-expressions can continue to have similar semantics to block statements, and the new proposal can be more IIFE-like.

theScottyJam commented 3 years ago

@getify - I'm struggling a bit to understand the pattern-matching docs on this point that you're bringing up. In your first code snippet, where you omit the {}, is the RHS of a when just an expression (and not a do block)? If so, wouldn't that first code example be possible, whether or not we require an explicit completion-value marker? If we wanted to compare two pattern-matching expressions, wouldn't we have to compare two that use {} (and statements) in the RHS?

Or am I completely missing your point?

getify commented 3 years ago

@theScottyJam

...where you omit the {}, is the RHS of a when just an expression (and not a do block)?

Correct!

I'm struggling a bit to understand... Or am I completely missing your point?

TBF, I didn't make my point(s) very explicitly... I implied a bit and left it mostly for inference.

So... the first snippet was intended to point out a cleaner (IMO) option to ternary as presented in the previous comment. Further, it's another example of the terseness appeal, but doesn't need any do-expressions to do so.

The second snippet illustrates the impact if do-expressions (the RHS with braces) require the give marker -- but only if you choose to use the braces form! Some might say it's indeed too burdensome. Others might say it's not as bad as the if example, so it's still reasonable. I leave that judgement up to the reader.

But moreover, my biggest point was refutation that making give required necessarily harms the use-case you presented. Pattern-matching with terse RHS avoids the do-expressions altogether, rendering that give point somewhat moot.

theScottyJam commented 3 years ago

Ah, I get it now, thanks :) - and yes, that first match example is a much nicer way to achieve the intended goal than a nested ternary - you're making me want the pattern matching syntax even more now.

Ontopic commented 3 years ago

I personally like to use this at the moment for JSX

const Component = do {
    const Something = (props) => {...}
    const Item = (props) => {...}

    1 && function Component(props) {
        return (
            <Something>
                <Item />
            </Something>
        )
    }
}
Ontopic commented 3 years ago

You could change the 1 && to something like a constant, as long as it evaluates to true.


const DONE = 1
// or const RETURN = 1
// or const RESULT = 1
// or const UNICORN = 1
// or const GO = 'wild'

const X = do {
    const scoped = 1

    DONE && function X(){
        console.log(scoped)
    }
}
EdSaleh commented 3 years ago

https://github.com/tc39/proposal-do-expressions/issues/74

theScottyJam commented 2 years ago

In order to keep these topics cleaner, I'm going to go ahead and move discussion around mandatory explicit completion values to this thread. Any discussion around optional explicit completion value, or the syntax around it can of course be left here.