Warning: Undefined array key -1 in /home/public/blog/class.comments.php on line 23

Warning: Attempt to read property "i" on null in /home/public/blog/class.comments.php on line 23

New Syntax for JS "Function Stuff"

Last updated:

For the last little while, various people in TC39 have been developing several different proposed additions to JS, all trying to make various sorts of "function manipulation" easier and more convenient to work with.

At this point it's clear that TC39 isn't interested in accepting all of the proposals, and would ideally like to find a single proposal to accept and reject the rest. This post is an attempt to holistically lay out the problem space, see what problems the various proposals address well, and find the minimal set of syntax proposals that will address all the problems (or at least, help other people decide which problems they feel are worth fixing, and determine which syntaxes cover those problems).

(Note, this post is subject to heavy addition/revision as I learn more stuff. In particular, the conclusion at the end is subject to revision as we add more problems or proposals, or decide that some of the problems aren't worth solving.)

The Problems

As far as I can tell, these are the problems that have been brought up so far:

  1. .call is annoying

    If you want to rip a method off of one object and use it on an arbitrary other object as if it were a method of the second object, right now you have to either actually assign the method to the second object and call it as a method (obj.meth = meth; obj.meth(arg1, arg2);), or use the extremely awkward .call operation (meth.call(obj, arg1, arg2)).

    This sort of thing is useful for generic protocols; for example, most Array methods will work on any object with indexed properties and a length property. We'd also like to, for example, create methods usable on arbitrary iterables, without forcing authors into a totally different calling pattern from how they'd work on arrays (map(iter, fn) vs arr.map(fn)).

    Relatedly, method-chaining is a common API shape, where you start from some object and then repeatedly call mutating methods on it (or pure methods that return new instances), like foo.bar().baz(). This API shape can't easily be done without the functions actually being properties of the object, and the syntax variants are bad/confusing to write (baz(bar(foo)), for example).

  2. .bind is annoying

    If you want to store a reference to an object's method (or just use it inline, like arr.map(obj.meth)), you can't do the obvious let foo = obj.meth;, because it loses its this reference and won't work right. You instead have to write let foo = obj.meth.bind(obj); which is super annoying (and impossible if obj is actually an expression returning an object...), or write let foo = (...args) => obj.meth(...args);, which is less annoying but more verbose than we'd prefer.

  3. Heavily-nested calls are annoying.

    Particularly when writing good functional code (but fairly present in any decently-written JS imo), a lot of variable transformations are just passing a value thru multiple functions. There are only two ways to do this, both of which kinda suck.

    The first is to nest the calls: foo(bar(baz(value))). This is bad because it hides a lot of detail in minute structural bits, particularly if some of the functions take more than one argument. You end up having to do some non-trivial parsing yourself while reading it, to match up parens appropriately, and it's not uncommon to mess this up while writing or editing the code, putting too many or too few close-parens in some spots, or putting an arg-list comma in the wrong spot. You can make this a little better with heavy line-breaking and indentation, but then there's still a frustrating rightward march in your code, it's still hard to edit, and multi-arg functions are still hard to read (and really easy to forget the arg-list commas for!), because the additional arguments might be a good bit further down the page, by which point you've already lost your train of thought following the nesting of the first argument.

    The second way to handle this is to unroll the expression into a number of variable assignments, with the innermost part coming first and gradually building up into your answer. This does make reading and writing much less error-prone, but lots of small temporary variables come with their own problems. You now have to come up with names for these silly little single-use variables, and it's not immediately clear that they're single-use and can be ignored as soon as they get used in the next line. (And unless you create a dummy block, the variable names are in scope for the rest of the block, allowing for accidental reference.) Some of the temporary variables might have a meaningful concept behind them and be worthy of a name, but many are likely just semantically a "partially-processed value" and thus not worthy of anything more meaningful than temp1/temp2/etc.

    Further, this changes the shape of the code - what was once an expression that could be dropped inline anywhere is now a series of statements, which is much more limited in placement. For example, this expression might have been in the head of an if expression, and now has to be moved out to before it; this prevents you from doing easy else if chains.

  4. Partially-applying a function is annoying.

    If you want to take an existing function and fill in some of its arguments, but leave it as a function with the rest to be filled in later, right now you have to write something like let partialFoo = (arg1, arg3) => foo(arg1, value, arg3);. This is more verbose and annoying than ideal, especially since this sort of "partial application" is very common in functional programming (for example, filling in all but one of a function's arguments, then passing it to .map()).

    In particular, the problem here is that the important part of the expression is the arguments you're filling in, but the way you write it instead requires naming all the parts you're not filling in, then referencing those names a second time in the actual call, obscuring the values you're actually pre-filling. This is also especially awkward in JS if your function takes an option-bag argument and you're trying to fill in some of those arguments, but let the later caller fill in the rest; you have to do some shenanigans with Object.assign to make it work.

  5. Supporting functor & friends is annoying

    "Functor", "Applicative, "Monad", and others are ridiculous names, but represent surprisingly robust and useful abstractions that FPers have been using for years, capturing very common code patterns into reusable methods. The core operation between them is some variant of "mapping" a function over the values contained inside the object; the problem is that in JS, this is always done with an inverted fn/val relationship vs calling: rather than fn(val), you always have to write val.map(fn) or some variant thereof.

    JS does specially recognize one functor, the Promise functor, with special syntax allowing you to treat it more "normally"; you can call fn(await val) rather than having to write val.then(fn). Languages like Python also have some specialized syntax for the Array functor in the form of list comprehensions, letting you write a normal function call. But in heavily-FP languages, there's generally a generic construct for dealing with functors in this way, such as the "do-notation" of Haskell, which both makes it easier to work with such constructs, and makes it easier to recognize and reason about them, rather than having to untangle the specialized and ad-hoc interactions JS has to deal with today.

The Possible Solutions

There are a bunch! I'll list them in no particular order:

  1. "F#" pipeline operator, spelled |>. Takes a value on the LHS and a function on the RHS, calls the function on the value. So "foo" |> capitalize yields "FOO". You can chain this to continue piping the result to more functions, like val |> fn1 |> fn2.

  2. "Smart mix" pipeline operator, also spelled |>. Takes a value on the LHS, and an expression on the RHS: if the expression is of a particularly simple "bare form", like val |> foo.bar, it treats it like a function call, desugaring to foo.bar(val); otherwise the RHS is just a normal expression, but must have a # somewhere indicating where the value is to be "piped in", like val |> foo.bar(#+2), which desugars to foo.bar(val+2).

    Smart-mix also has the closely-related pipeline-function prefix operator +>, where +> foo.bar(#+2) is a shorthand for x=> x |> foo.bar(#+2), with some niceties handling some common situations.

  3. Call operator, spelled ::. Takes an object on the LHS and a function-invocation on the RHS, calls the function as a method of the object. That is, given foo::bar(), this ends up calling bar.call(foo). The point of this is that it looks like just calling foo.bar(), but it doesn't require that the bar method actually live on the foo object.

    Can also be used as a prefix operator, called the "bind" operator. Takes a method-extraction on the RHS, and returns that method with its this appropriately bound. That is, given ::foo.bar, this ends up calling foo.bar.bind(foo).

  4. Partial-function syntax, spelled func(1, ?, 3). Implicitly defines a function that takes arguments equal to the number of ? glyphs, and subs them into the expression in order when called.

  5. Others?

Which Solutions Solve Which Problems?

  • The F# pipeline operator solves problem 3 partially. (You can unnest plain, unary function calls easily. Anything else requires arrow functions, or using functional tools that can manipulate functions into other functions.)

    Paired with partial-functions it solves more cases easily, but not all. You can write val |> foo(?, 2) to pipe into n-ary functions, but still can't handle await, operator expressions, etc. Can technically do val |> foo.call(?, ...) as the equivalent to smart mix's val |> #.foo(...) or call operator's val::foo(...), but kinda awkward.

  • The "smart mix" pipeline operator solves problem 3 more completely. (With topic-form syntax you can trivially unnest anything. Bare-form syntax lets you do some common "tower of unary functions" stuff with a few less characters, same as "F#" style.)

  • The "smart mix" pipeline-function operator solves problems 2 and 4 well. (With bare-form syntax, +>foo.bar creates a function that calls foo.bar(...), solving the bind problem in two characters. With topic-form syntax, +>foo(#, 2, ##) fills in the second argument of foo() and creates a function that'll accept the rest. Option-bag merging is still difficult/annoying.)

  • The call operator solves problem 1 well. If you write the ecosystem well, it also solves problem 5 okay. (For example, write a generic map function that takes the object as this and a function as argument, and calls this.[Symbol.get("fmap")](fn). Then if the functor object defines a "fmap" operation, you can write obj::map(fn1)::map(fn2), similar to Haskell's obj >>= fn1 >>= fn2 syntax. )

  • The bind operator solves problem 2 well.

  • The partial-function operator solves problem 4 okay, but with some issues. (Unclear what the scope of the function is - in let result = foo(bar(), baz(?)), is that equivalent to let result = foo(bar(), x=>baz(x));, or let result = x=>foo(bar(), baz(x));? Related to that, is foo(?, bar(?)) two nested partial functions, or a single partial function taking two arguments? Can you write a partial function that only uses some of the passed-in arguments, or uses them in a different order than they are passed in?)

So, inverting this list:

  1. The call problem is well-solved by the call operator only.
  2. The bind problem is well-solved by the bind operator, and the bare-syntax pipeline-function operator. (They differ on whether the method is extracted/bound immediately (bind operator), or at time of use (pipeline-function operator).)
  3. The nesting problem is somewhat solved by "F#" pipeline operator, and better solved by "smart mix" pipeline operator.
  4. The partial-function problem is somewhat solved by the partial-function operator, and better solved by the topic-syntax pipeline-function operator.
  5. The functor problem is somewhat solved by the call operator, but not super well.

So, if you think all the problems deserve to be solved, currently the minimal set that does everything pretty well is: call operator, "smart mix" pipeline, and pipeline function.

(a limited set of Markdown is supported)