There’s a hacky demo of these suggestions in this ASTExplorer playground, so you can play with it and see how it generates the code.
When I first saw the postfix
await operator in Rust, I was very confused. It looked very weird. After a while, it became much clearer to me that it makes reading code much easier to me.
It makes sense: when I’m reading English, I expect the text to be from left to right (not in Hebrew though, but whatever). Code is usually written in English and from left to right too. There are some exceptions to that: prefix operators.
I think these should be avoided as they’re harder to follow. I’m talking specifically on
await here, but I think that a
.not() function would be great for booleans.
To understand the confusion and why postfix operators are better for these cases, let’s look at some code.
Imagine that we have a calculator library that we’re willing to use, and we’re given with the following code:
const value = number(0).add(5).add(3).divide(2)
In my opinion, this code is relatively easy to read and understand: the dot-notation is very clear—we can read it from left to right and it makes total sense. What would happen if the API changed into an asynchronous one as if we were running the calculator in a different thread/worker?
If the API were async, each function call would be prefixed with
await to keep the logic the same:
const value = await (await (await (await number(0)).add(5)).add(3)).divide(2)
How’s that code compared to the previous one? Not so good, right? A good way to overcome this is by using
promise.then(...) but that’s cheating as we stop using the prefix operator and opt for a postfix function call. Another option would be to break this code into multiple lines, even when really unnecessary as it’s not a complicated calculation.
So, how can we fix that? Well, what if
await was a postfix operator and worked like an attribute/property that is accessible on any object?
// before const value = number(0).add(5).add(3).divide(2); // prefix await const value = await (await (await (await number(0)).add(5)).add(3)).divide(2); // postfix await const value = number(0).await.add(5).await.add(3).await.divide(2).await;
obj.await instead of
await obj allows us to chain the calls and have a clear left-to-right flow that is easy to read, easy to write, and easy to update when necessary.
The bind operator ::
This is a TC39 proposal that did not take off. Its premise is to allow calling a function while binding
this to the left hand operand. So:
something::myFunction() will be compiled and treated just like
myFunction.call(something). You might ask yourself, why is this any good?
Dot-notation is easier to read
See the following examples:
// #1 concatWith(" world!", capitalize(trim(greeting))) // #2 greeting::trim()::capitalize()::concatWith(" world!")
Which one is easier to read? The bind operator allows seeing the data flow from left to right. The bind operator looks like the dot notation, with two colons instead of a single dot. This brings a very similar behavior and expectation, while still being easy on the eyes, compared to the pipeline operator (which is also good!)
Free functions are tree-shakable and minifiable
Unlike methods on classes and objects, free functions are easily tree-shakeable, easily minified, and allow dead code elimination. That means that if you never call
myHugeFunction and its dependencies can be removed from the bundle safely, resulting in less code sent to the user. What a win:
- You don’t reference them by name, but through a reference that can be statically analyzable. This allows minifying
myfunctionwas minified to
- We can statically know whether a function was used from an import, making it easier to follow usages for properly tree-shaking what we need, for smaller production artifacts.
Free functions are better for modularity
Older standard library extensions used to monkey patch the browser's classes, like Array and String, to provide methods like
String#capitalize, etc, so they were used like
lodash and other modern libraries however, are a set of functions that can work against the data structures as input, instead of adding it to the original class prototype. So the previous example would be
capitalize("hello"). This is both non-destructive for the prototype, and enables all the tree-shaking described in the previous section.
That also means anyone can create their own functions that operate on such data structures. Maybe
array::groupByIntoMap(value => key) that groups a set of values into a map? Maybe an
iter::map(v => v) function that operates on any iterator and not just arrays?
This is a powerful feature, because we can encapsulate this logic in modules, ship them, and share them with other projects. People can keep extending them, with no need of surgical interference or monkey-patching.
It’s fairly easier to type using TypeScript
Not having to use TypeScript’s Augmentation feature is a big win here. It leaves you with implementing your function, which can allow arbitrary generics. What if you want to support a function that only operates on array of
numbers and not on array of
strings? That’s totally possible and not a real issue at all.
Because, you know, it is just a function.
Easier to grasp over pipeline operator
The proposed pipeline operator (
|>) can solve most of these issues, and frankly, I'm excited for it. However, I think that newcomers will feel more at home with the bind operator, because it fits the dot notation over "function composition" which might be intimidating for them.
I really hope that in the near future, the pipeline and/or bind operators will be stable.
🚮 Unfortunately, I do not use these syntax extensions
- I do not like custom solutions, and rather use industry standard to make sure everyone can contribute to my code with ease, including the future me, which might forget about these code transformations.
- The code might be able to run, but in my opinion, it worths nothing if TypeScript, Prettier or other utilities and static analysis tools are broken. They are much more important than making my code a bit nicer to my own eyes.