DISCLAIMER: Do NOT use this is production code. It's just a fun idea (full of potential danger) that could lead to something more useful...
Okay, so we're going to make the following JS code work, even though it doesn't look like it should:
const addOne = x => x + 1
const half = x => x / 2
const result = addOne[half][Math.floor](4)
console.log(result) // 2
How? Through the magic of JS features that you:
- Have always been told (weirdly) to avoid: type coercion
- Know about, but can't think of a use for: Symbols
Regular viewers who've seen previous episodes such as 'Metho', 'Turboprop', and 'Ranger' will probably have a fairly good idea of how we're going to achieve this...
The Goal
For the pipelining to work, essentially all we need is to make the following true:
f[g] = x => g(f(x))
- For ANY two functions f and g:
- The function f must have a method named with g
- That method should return another function that is g composed with f
If we can achieve this, we can essentially make a pipeline of as many functions as we like.
The Solution
Below is one way to achieve what we want. Please ask in the comments if there's anything you don't understand:
Function.prototype[Symbol.toPrimitive] = function (hint) {
// retain as much 'normal' coercion behaviour as we can
if (hint === "number") return NaN
if (hint === "default") return this.toString()
// store a reference to the function being coerced - for later use
const fn = this
// create a unique symbol to use as the method key
const token = Symbol()
// create a unique method on the Function prototype
Object.defineProperty(Function.prototype, token, {
configurable: true,
// use a getter so we have access to the target function (this)
// when creating the method to return
get() {
// remove the method when used - single use only
delete Function.prototype[token]
// the method itself - our composed function x => g(f(x))
return x => fn(this(x))
},
})
// self cleanup for stray coercions that could occur outside
// of an intended pipeline
queueMicrotask(() => {
delete Function.prototype[token]
})
// return the symbol we 'named' the method with - so it is
// immediately invoked
return token
}
References (MDN):
Why This is Dangerous?
This is obviously dangerous and/or flaky for a number of reasons:
- We're adding properties to language-level prototypes. Generally a pretty bad idea, but we're adding properties keyed with Symbols so not risking collisions. However, there's still no guarantee this will not cause issues
- Anything relying on coercion of functions to string primitives could well be trashed as we've modified the standard behaviour
- It's not optimised - many identical functions will be recreated again and again
- It will almost certainly have issues pipelining functions that use
this- although I'm not even sure what functionality one would expect in that case
How Could it be Made Safer/Better?
The main way it could be made safer would probably be to mark/subclass pipeline-able functions in some way - so that we could leave standard function behaviour intact. The problem with this is that we'd probably lose the nice syntax, or at the very least end up with something less elegant.
A way to improve it might be to give the intermediate functions created meaningful names - might be nice in stack traces.
Why do this?
Why not? 😀
Top comments (0)