Proposal: "as" operator for monadic operations

const a = Math.random()
  as(x) { x * x + 2 }
  as(x) { x * 3 }
  .toString();

It is basically ((x) => {})(x) but with correct ordering and without the extra function scope.
It's also a clean and flexible replacement for

without introducing new keywords and operators and thus easy to memorize (IMHO).
The scope for the new identifier is concisely & explicitly defined with {}.

It may have collisions with the TypeScript as operator, but it can be distinguished by requiring the (){} syntax. I'm also open to suggestions.

More example usages:

// Temporary debugging without creating new variables
function add_1(a) {
  return a + 1
    as(x) {
      console.log(x);
      x
    };
}
// No more nested await expressions and extra variable names
const data = await fetch("http://example.com/movies.json")
  as(res) { await res.json() }
  as(data) {
    console.assert(data['success'] == 1);
    data['content']
  };
4 Likes

An extension of this proposal is the use of as keyword with iterators:

const arr = iter
  as for(x) {
    if (x > 3) continue; // filtering
    if (x < 0) break; // early exit
    x * 2 // map
  }
  as(iter) { Array.from(iter) };

Ease of map and filter with iterators is one of the things I miss most from Python.
Current implementation requires conversion to an array (Array.from(iterator).map(x => x * 2)) or a generator function.

Alternative:

1 Like

This is actually a fairly good solution for a lot of things in my opinion. I'd like to know what others think about this.

Can I do the following?

const nextNumber = as {
  let num = 0;
  () => ++num;
};

console.log(nextNumber());
1 Like

This is similar to IIFE and do expressions, which is one of the intended usages. I prefer not to simplify the syntax around as to ensure best parsing compatibility. It should work with the following modification:

const nextNumber = 0 as(_) { // 0 can be anything here, e.g. `{}`
  let num = 0;
  () => ++num;
};

console.log(nextNumber());

It doesn't actually replace the wavy dot proposal. A side-effect of that proposal is that it's easier to chain promises, but the main purpose is to provide a syntax for the eventual send proposal. I learned this the hard way, because I had tried suggesting a replacement for wavy-dot in the past as well, just to learn that I had greatly misunderstood the nature of that proposal :).

It still can be used to replace both pipelines and do expressions. Though, to me, this suggestion sort of just feels like both of those mashed together in one syntax, making it naturally capable of handling both of these proposals, but it's not as great at either one (it's more verbose as a pipeline, and requires a dummy value as a do expression).

That being said, I'd be ok using syntax like this as my pipeline operator. And if there were some alternative form that didn't require a dummy value (that just feels gross to have syntax actively encourage the use of throw-away values like that), I'd be ok using it for do expressions as well. And, it does mean we only have one syntax change rather than two, which is a nice bonus.

I do, however, expect that there would be quite a bit of pushback for having something like this replace pipelines, because:

  • One of the strong arguments they've been using to push pipelines forwards is that "naming things is hard", and pipelines make it so you don't have to name a bunch of intermediate variables. This proposal would require us to explicitly name something at each step, which goes against one of the main arguments being put forwards for the current pipeline proposal.

  • The pipeline proposal was divided between two possible syntaxes for a long time, and one of the arguments that kept being brought up to decide between the two is how verbose each syntax was in different scenarios - often bringing up the letter count. This proposal is significantly more verbose than either of those two syntaxes.

  • The fact that there was such a long and hard debate over syntax, and now they've finally decided on a route, they seem very, very reluctant to consider further syntax changes. There's fear that if they were to keep changing their mind after this point, that the pipeline operator (in any form, presumably this form as well) just won't make it into the language at all.

I personally don't really agree with the arguments about "naming is hard" or "verbosity". For "naming is hard", I've never felt that sentiment as I coded, especially since we're just talking about local variables (not module exports), where the reader also has context to help them understand what's going on. I like pipelines for other reasons, like how it helps prevent the scope from being bloated with a ton of one-time-use variables. And, verbosity was often brought up mainly as a way to compare those two competing syntaxes, but in reality, I think it's fine for it to be a little verbose.

1 Like

@theScottyJam Thank you very much for your detailed review! I learned a lot from your suggestions.

I just checked out the eventual send proposal. I agree it is a good use case for the wavy dot. This proposal works, too, but not as simple as the wavy dot. The same applies to pipelines and do expressions.

You're totally right about smashing up use cases inside one syntax. I have the same feeling, too. It especially reminds me of template literals. But IMHO solving problems with fewer syntaxes is better than more. JS has already seen ===, !==, >>>, ?., ??, #, while people are trying to push ~., do, ||==, :, |>, @, etc. into the spec. "Succinct" syntaxes are a learning burden for programmers because they are harder to search on Google. JS is even more bloated than C++ in terms of the number of operators.

I believe that verbosity and the letter count are not the real problems. Readability is. Looking at the examples of the pipeline proposal, it needs a line for each operation anyway. The as operator wouldn't cost more.

"Naming things is hard" is also a topic with diverse opinions. Naming everything as % is not a true solution and it makes it even harder for people transiting from other languages to recognize this as an identifier. Using it like Kotlin could be better, but it might be too late to add this as a JS keyword. Additionally, % brings back the variable scope problem. (Remember var?)

I haven't come up with a way to fully replace do expressions without dummy variables. The expression before as is necessary, otherwise JS parsers might insert auto-semicolons and cause ambiguity.

This seems like a let expression with a funny syntax. I would prefer having actual let and const expressions in the language instead of the proposed "as" expressions.

const a =
  const x = Math.random(),
        y = x * x + 2,
        z = y * 3
  in z.toString();

A let or const expression with multiple declarators can be translated into multiple nested let or const expressions. For example, the above const expression is equivalent to the following.

const a =
  const x = Math.random() in
  const y = x * x + 2 in
  const z = y * 3 in
  z.toString();

In my humble opinion, let and const expressions are more idiomatic than the proposed "as" expressions. They're easier to read and understand, and they are already familiar to most functional programmers.

We can also use the comma operator to run side effects before returning the value of the expression.

function add1(a) {
  return const x = a + 1 in (console.log(x), x);
}

const data =
  const res = await fetch("http://example.com/movies.json"),
        data = await res.json()
  in (console.assert(data.success === 1), data.content);

This is even cleaner with the proposed do expressions.

function add1(a) {
  return const x = a + 1 in do {
    console.log(x);
    x
  };
}

const data =
  const res = await fetch("http://example.com/movies.json"),
        data = await res.json()
  in do {
    console.assert(data.success === 1);
    data.content
  };

Curious - what do you mean by this?

Hi @aaditmshah , I like the feel of the in syntax. But note how close we can get to it in JS today. Your first example above is approx equivalent to

const a = ((
    x = Math.random(),
    y = x * x + 2,
    z = y * 3
  ) => z.toString()
)();

Makes me curious how well the other motivating examples for pipeline, wavy-dot, or as could be written in JS today using this pattern?

Oh... wow... that's quite the abuse of functions and default parameters, but you're right that it does get us really close. That's quite impressive actually.

@aaditmshah Apologies for my limited expertise in functional programming. How is your first example different from using normal let statements?

const x = Math.random();
const y = x * x + 2;
const z = y * 3;
const a = z.toString();

And the difference with the proposed as expression I can think of is that the data flow in let expression can be a DAG rather than a pipeline:

const a = 
  const x = Math.random() in
  const y = x * x in
  const z = x + 2 in
  (y + z);

But in this case, I think we should fall back to the normal control flow to explicitly state the data dependency.

I can't speak for the usefulness of let expressions. But from the software engineering perspective, let expressions violate a principle (or coincidence) of declaring variables and make it harder to find the declaration of variables. I can peek at a line and quickly know there's a declaration if the line starts with let, const, import, for, function, etc. I could find every local declaration with the regex ^\s*const . Now how about this?

console.log("The sum of two numbers is ", a, "+", b, "=", const c = a + b in c);

This is Pandora's box that requires serious consideration before opening.

Another problem with let expressions is the scope problem. Should the const variable be available outside the declaration ()? That's something a developer cannot infer from the syntax, and one has to look up the spec if it has moved to another language to work on a project for some weeks and just moved back to JS.

The scope problem is something I would personally avoid. JS once had the infamous var declaration:

console.log(foo); // undefined
if(true) {
    var foo = "bar";
}
console.log(foo); // bar

@theScottyJam The % variable in the pipeline proposal brings this problem back, in an inversed way. The scope might appear to be different than expected:

const a = 1 |> (2 |> % * %);

How do we distinguish (2 |> %) * % and 2 |> (% * %)? Again, we need to look up the operator precedence before we can be sure. It's not a well-known precedence like + and *. It's JS-specific.

Of course, you are able to write correct code if you are careful, just as how you know the scope problem of var. But we want it to be obvious, and so obvious that we can grab a piece of code and understand what it is exactly doing without looking up the spec.

@markm This is really an intriguing trick! As for the difference:

  • The as expressions & do expressions have the extra power of embedding for and if statements.
  • The as expressions & do expressions can use await, while function parameters cannot.

    It is a Syntax Error if CoverCallExpressionAndAsyncArrowHead Contains AwaitExpression is true.

  • The as expression & the pipeline proposal can discard temporary variables for GC.
  • The as expression & the pipeline proposal also enable clean refactor history in line diff (e.g. git). This is also the purpose of trailing commas, I believe.
     const a = ((
         x = Math.random(),
    -    y = x * x + 2,
    -    z = y * 3
    +    z = x * 3,
       ) => z.toString()
     )();
    
     const a = Math.random()
    -  as(x) { x * x + 2 }
       as(x) { x * 3 }
       .toString();
    

The scope of let expressions is very explicit. You have a bunch of comma-separated const declarations that end with an in <expression>. A declaration can only be used in further declarations in the chain, and in the final in <expression>. So to answer the question of " Should the const variable be available outside the declaration () ?", I assume you're referring to the console.log() parentheses, in which case the answer is no, the declarations are only available to further declarations in the chain, and in the final in <expression>, and that's it.

Perhaps you could worry about the actual precedence of the in operator, and how "far to the right" you can go and still use the variables, in which case we could make it even more concrete by making the syntax instead be something like "[const <varName> = <expression>,]+ in <expression> end". (with an end at the end), at which point there should be no question about where the scope boundaries are.

Hopefully this helps clear up your concern about "quickly being able to find local variables" - these variables aren't local to the function, there's only local to a part of an expression, so I don't see a particular reason to need to quickly spot those. It's really not any different from doing this:

console.log("The sum of two numbers is ", a, "+", b, "=", a + b as (c) { c });

c in this case is also a variable that's local to just a portion of the expression, and you didn't seem overly worried about how hard it was to notice that c was being declared in there.


All this being said, I'm not necessarily advocating for or against a syntax like const-in to solve this specific problem. I do personally like this kind of syntax, but I'm also not sure if it's solving the same problem that the as () {} was trying to solve.

1 Like

I see. Thanks for clearing it up!

Hello @markm. Yes, you have the right intuition. Let expressions can be considered syntactic sugar for lambda expressions, i.e. anonymous functions, applied to values.

const x = 2 + 3 in x + x

// is equivalent to

((x) => x + x)(2 + 3)

Lisp programmers know this as the left-left-lambda pattern because it's written in Lisp as follows — two left parens followed by the "lambda" keyword.

((lambda (x) (+ x x)) (+ 2 3))

; is equivalent to

(let ((x (+ 2 3))) (+ x x))

In fact, I posted a StackOverflow Q&A back in 2018 which demonstrated how to simulate let expressions in JavaScript using arrow functions and default parameters, just as you suggested now.

@theScottyJam did a good job of explaining the difference, but because you directed the question at me I'll provide an answer.

The difference is the scope of the const references. In a const expression, the newly created references can only be used in subsequent declarators (for example the reference x can be used in the declarator for y but not vice-versa) or in the expression immediately after the in keyword. The references can't be used outside the const expression.

const a =
  const x = Math.random(),
        y = x * x + 2,
        z = y * 3
  in z.toString();

console.log(x); // ReferenceError: x is not defined

On the other hand, if you use normal const declarations then you can access the intermediate references too.

const x = Math.random();
const y = x * x + 2;
const z = y * 3;
const a = z.toString();

console.log(x); // no reference error

Yes, let and const expressions allow references to intermediate values without the need for nesting. If you wanted to do the same thing with your proposed "as" expressions then the code wouldn't look as appealing.

const a = Math.random() as(x) {
  x * x as(y) {
    x + 2 as(z) {
      y + z
    }
  }
}

By the way, I'm not advocating for let or const expressions either. They are superseded by the "do" expression proposal, which I support.

const a = do {
  const x = Math.random();
  const y = x * x;
  const z = x + 2;
  y + z
};

function add1(a) {
  return do {
    const x = a + 1;
    console.log(x);
    x
  };
}

const data = do {
  const res = await fetch("http://example.com/movies.json");
  const data = await res.json();
  console.assert(data.success === 1);
  data.content
};