If you want the theory, this is roughly modeled on functors, with a few pragmatic concessions.
Function composition has been used for years, even in JS applications. It's one thing people have been continually reinventing as well. Many utility belts I've found have this function – in particular, most common ones have it:
- Underscore:
_.compose
- Lodash:
_.flow
and_.flowRight
- Ramda:
R.compose
andR.pipe
There's also the numerous npm modules and manual implementations (it's trivial to write a basic implementation). Conceptually, it's pretty basic:
function composeRight(f, ...fs) {
return function () {
var result = f.apply(this, arguments);
for (var i = 0; i < fs.length; i++) {
result = fs[i].call(this, result);
}
return result;
}
}
It lets you do turn code like this:
function toSlug(input) {
return encodeURIComponent(
input.split(" ")
.map(str => str.toLowerCase())
.join("-")
)
}
to this:
const toSlug = composeRight(
_ => _.split(" "),
_ => _.map(str => str.toLowerCase()),
_ => _.join("-"),
encodeURIComponent
)
Or, using this proposal:
const toSlug =
_ => _.split(" ")
:> _ => _.map(str => str.toLowerCase())
:> _ => _.join("-")
:> encodeURIComponent
Another scenario is when you just want to trivially transform a collection. Array.prototype.map
exists already for this purpose, but we can do that for maps and sets, too. This would let you turn code from this, to re-borrow a previous example:
function toSlug(input) {
return encodeURIComponent(
input.split(" ")
.map(str => str.toLowerCase())
.join("-")
)
}
to something that's a little less nested (in tandem with the pipeline operator proposal):
function toSlug(string) {
return string
|> _ => _.split(" ")
:> word => word.toLowerCase()
|> _ => _.join("-")
|> encodeURIComponent
}
These are, of course, very convenient functions to have, but it's very inefficient to implement at the language level. Instead, if it was implemented at the engine level, you could optimize it in ways not possible at the language level:
-
It's possible to create composed function pipelines which are as fast, if not faster, than standard function calls.
-
Engines can trivially optimize and merge pipelines as appropriate. In the example language implementation for function composition, which is the usual optimized function implementation,
result
would be quickly marked as megamorphic, because the engine only has one point to rule them all for type feedback, not the n - 1 required to reliably avoid the mess. (Of course, this could be addressed by a userlandFunction.compose
or whatever, but it still fails to address the general case.) -
The call sequence can be special-cased for many of these internal operations, knowing they require minimal stack manipulation and are relatively trivial to implement.
Here's what I propose:
- A new low-precedence
x :> f
left-associative infix operator for left-to-right lifted pipelines. - An async variant
x :> async f
for pipelines with async return values and/or callbacks. - An async variant
x :> await f
that is sugar forawait (x :> async f)
- Two new well-known symbols
@@lift
and@@asyncLift
that are used by those pipeline operators to dispatch based on type.
The pipeline operators simply call Symbol.lift
/Symbol.asyncLift
:
function pipe(x, f) {
if (typeof func !== "function") throw new TypeError()
return x[Symbol.lift](x => f(x))
}
async function asyncPipe(x, f) {
if (typeof func !== "function") throw new TypeError()
return x[Symbol.asyncLift](async x => f(x))
}
Here's how that Symbol.lift
would be implemented for some of these types (Symbol.asyncLift
would be nearly identical for each of these):
-
Function.prototype[Symbol.lift]
: binary function composition like this:Function.prototype[Symbol.lift] = function (g) { const f = this // Note: this should only be callable. return function (...args) { return g.call(this, f.call(this, ...args)) } }
-
Array.prototype[Symbol.lift]
: Equivalent toArray.prototype.map
, but only calling the callback with one argument. (This enables optimizations not generally possible withArray.prototype.map
, like eliding intermediate array allocations.) -
Promise.prototype[Symbol.lift]
: Equivalent toPromise.prototype.then
, if passed only one argument. -
Iterable.prototype[Symbol.lift]
: Returns an iterable that does this:Iterable.prototype[Symbol.lift] = function (func) { return { next: v => { const {done, value} = this.next(v) return {done, value: done ? value : func(value)} }, throw: v => this.throw(v), return: v => this.return(v), } }
-
Map.prototype[Symbol.lift]
: Map iteration/update like this:Map.prototype[Symbol.lift] = function (func) { const result = new this.constructor() for (const pair of this) { const [newKey, newValue] = func(pair) result.set(newKey, newValue) } return result }
-
Set.prototype[Symbol.lift]
: Set iteration/update like this:Set.prototype[Symbol.lift] = function (func) { const result = new this.constructor() for (const value of this) { result.add(func(value)) } return result }