Return-as-expression strawman

I don't expect this probably has any place in ECMAScript (sadly) because I can't figure out a way to syntax it without it being confusing, but I wanted to get some input on a nice-to-have that I've wanted for some time now. The idea is to change the semantics of return to be a unary prefix operator of lowest precedence that:

  • evaluates to its argument
  • stores its argument in the current function's "return slot"
  • triggers end-of-function at the end of the current statement

Which is a strict superset of the current return semantics. The times when I wish for it hardest are when I'm debugging and I want to trace function entry/exit:

// original function:
function sumOrNone(x, y) {
    if (x === null) return y;
    if (y === null) return x;
    return x + y;
}

//add debug tracing, return-as-expression syntax:
function sumOrNone(x, y) {
    console.log("Called sumOrNone with arguments:", {x, y});
    if (x === null) console.log("x is null, returning y:", return y);
    if (y === null) console.log("y is null, returning x:", return x);
    console.log("neither is null, adding", return x + y);
}

//add debug tracing, current syntax:
function sumOrNone(x, y) {
    console.log("Called sumOrNone with arguments:", {x, y});
    if (x === null) {
        console.log("x is null, returning y:", y);
        return y;
    }
    if (y === null) {
        console.log("y is null, returning x:", x);
        return x;
    }
    const rv = x + y;
    console.log("neither is null, adding", rv);
    return rv;
}

With return-as-expression, if I have a function with a ton of return statements, I can instantly add logging to every one of them with a single find/replace. As it is now, I have to add braces, store return values, etc.

The downside is that it becomes harder to find which statements are possible function returns, as "return" can become buried and/or fall off the right edge of the screen, but that comes down to code style - it's already possible to put return in weird places, especially if you combine multiple statements per line. Not to mention, of course, that the semantics would need some serious hammering-on, like, what happens when a return expression is part of an await? What happens when you have multiple return expressions in a single statement?

I can't think of any direct piece of prior art, but C#'s throw expressions are similar (usually as a way to "abort with exception if this parameter is invalid", and return expressions could be used in a similar way for a more graceful function abort. The difference between the two is pretty huge, though: throw always aborts instantly, whereas return-as-expression has to defer the return until after the current statement is complete.

Thoughts?

A solution using existing techniques:

function trace(log, v) {
  console.log(log, v);
  return v;
}

function sumOrNone(x, y) {
    console.log("Called sumOrNone with arguments:", {x, y});
    if (x === null) return trace("x is null, returning y:", y);
    if (y === null) return trace("y is null, returning x:", x);
    return trace("neither is null, adding", x + y);
}

If there was GitHub - tc39/proposal-pipeline-operator: A proposal for adding a useful pipe operator to JavaScript.

function sumOrNone(x, y) {
    console.log("Called sumOrNone with arguments:", {x, y});
    if (x === null) return y |> (console.log("x is null, returning y:", ^^), ^^);
    if (y === null) return x |> (console.log("y is null, returning x:", ^^), ^^);
    return x + y |> (console.log("neither is null, adding", ^^) ^^);
}

Or combined with the trace function declaration before above:

function sumOrNone(x, y) {
    console.log("Called sumOrNone with arguments:", {x, y});
    if (x === null) return y |> trace("x is null, returning y:", ^^);
    if (y === null) return x |> trace("y is null, returning x:", ^^);
    return x + y |> trace("neither is null, adding", ^^);
}
1 Like

If the following proposal were to go through in it's current form, it would add a trace function like that, but it would be called "debugger.log()" instead: GitHub - tc39/proposal-standardized-debug: Standardized debug

3 Likes

I think this is a good place to clear a doubt of mine; Was there ever a thought/consideration to use return as an expression similar to proposal-throw-expressions?

It could be beneficial in all the places that proposal is looking at. And what about other jump statements like break and continue?

If a return-as-expression (or continue/break) were to behave in a way that was similar to throw-as-expression, then it would behave differently from what the O.P. described. More precisely,

function doit() {
  console.log('value is', return 2);
}

would return the number 2, but wouldn't log anything (when the return gets hit, the function immedietally returns). This is the same with how

function doit() {
  console.log('value is', throw new Error('!!!'));
}

will throw an error, and nothing will get logged. (when the throw gets hit, the error gets immediately thrown - no further things are evaluated in the function).

For the O.P.'s specific use case, I think some sort of trace()/debugger.log() method would work equally well, and is simpler to understand (doesn't require new syntax).

You're not wrong, and I swear I've had other use cases in mind (this isn't an infrequent wish of mine) but I haven't been able to remember what they are with my memory as fallible as it is :sweat_smile:

I think if return as expression works similar to throw expression proposal then I think it will have even greater implications and use cases than what OP mentioned.
For example:

function greetIfOdd(number) {
    console.log(number % 2 != 0
        ? "Hello Odd oneπŸ‘‹"
        : return
    )
}

To me this is a very strong argument against ever allowing it, since I find that incredibly hard to read and understand. Having exceptions be throwable at any point is hard enough; having to track normal completions coming from anywhere would be much harder.

Can you give an instance where it might mislead/confuse people?

My take is that continuation calls should be malleable like any other language feature even if that means "With great power comes greater complexity"

1 Like

Greater complexity should be avoided, imo.

I think this is a good enough reason to abandon this idea, because this doesn't do what you think it does given my original proposed semantics - it'll instead print out undefined, not abort the statement :sweat_smile:

Having done a lot of Rust lately, I'll agree to disagree here. In Rust, it's very common, with macros and even dedicated syntax (expr?, used to be a macro) to conditionally return. And I have no problem tracing the control flow.

If control flow tracking is truly a concern, I'll just use if/else and explicit assignment. If it hinders readability, I can just not use it. It's pretty easy to avoid when it's most problematic in my experience.

1 Like