Javascript Proxy and Partial Function Application
Matt Ellen-Tsivintzeli
Posted on August 11, 2022
This story starts, as all the best stories do, with a Stack Overflow question (before the answer had been posted, which, now I see, blows what I'm writing here out of the water π€·ββοΈ I'm still going to do it). The question being "How do you make infinite curry?" or something like that. I like curry*, so I took a look.
One of the comments on the question is:
...in an applicative language like JavaScript, it's not really "currying"
So, I thought to myself, if it's not currying, what is it? The currying article in Wikipedia makes this comment:
Currying is related to, but not the same as, partial application.
So I had a look at partial application.
Partial application is where you have a unary function (a function that only takes one argument) and it returns another unary function, and so on, until you have enough arguments. For example:
function add3Numbers(x,y,z)
{
return x + y + z;
}
Is a function that takes 3 arguments (x, y and z). To make it a partial application function, or rather functions, we can do this:
function add3Numbers(x)
{
return function(y)
{
return function(z)
{
return x+y+z;
}
}
}
Which can be invoked like so:
add3Numbers(11)(8)(2022);
But also
const add8yz = add3numbers(8);
add8yz(4)(5)
These toy examples don't really showcase the power of partial application, merely give you a taste of how it works. Its primary use is to make it easier to join functions together. Consider it akin to method chaining in object oriented languages, e.g.:
' abcdefghijklmnopqrstuvwxyz '.trim().toUpperCase().slice(3).replace('Q', 'q');
Where each method returns an object of the same type as the calling object.
What if, I pondered, I wanted to make a regular function into one capable of partial application? Can it be done?
The answer I have created is a resounding "sort of". I am using the .length
property of the function, which means it won't work for:
- Variadic functions (e.g.
console.log
). This is because the number of parameters is not defined. - Functions that use rest parameters (e.g.
function f(a, b, ...c){}
)..length
does not count rest parameters. - Functions with parameters that have default values (e.g.
function f(a, b=0){}
). In this case.length
ignores parameters with default values.
So my solution is limited to functions that have an explicit number of parameters, and no default values for the parameters.
Any of the following forms are valid:
const func1 = function(a,b,c){};
function func2(a,b,c){}
const func3 = (a,b,c) => {};
Where do we start? Well, let me introduce you to my good friend Proxy
.
With this we can pretend to call a function while actually doing something else.
The proxy needs a handler, to do the magic. I imagined the partial functions like a linked list:
const handler =
{
next: null,
apply: function(target, thisArg, argumentsList)
{
if(this.next)
{
const uc = new unaryCall(argumentsList[0]);
uc.args = thisArg.args.concat(argumentsList);
return (new Proxy(unaryCall, this.next)).bind(uc);
}
else
{
const args = thisArg.args.concat(argumentsList);
return initialFunction(...args);
}
}
};
The apply
function is what replaces the regular function call. You might notice that I don't just create a proxy, but I also bind it to an object.
I was struggling to figure out how I would preserve the arguments without overwriting them. Eventually I realised I would need to some how to modify the proxy I created, and concluded that I could set the thisArg
by binding the proxy.
So as to not hit some kind of recursion limit, each object that is bound to the proxy holds the arguments for itself and the previous objects. I think this gives faster access, but at the cost of taking up a lot more space.
At the end, i.e. if there is no next handler, then the handler returns the result of calling the original function with the accumulated arguments.
Building the list happens in a loop. I did consider recursion, as it would look more elegant, but the stack limit is smaller than the array size limit, so I erred on the side of caution:
let handlers = [];
for(let i = 0; i < initialFunction.length; i++)
{
const handler =
{
next: null,
apply: function(target, thisArg, argumentsList)
{
if(this.next)
{
const uc = new unaryCall(argumentsList[0]);
uc.args = thisArg.args.concat(argumentsList);
return (new Proxy(unaryCall, this.next)).bind(uc);
}
else
{
const args = thisArg.args.concat(argumentsList);
return initialFunction(...args);
}
}
};
if(handlers.length > 0)
{
const last = handlers[handlers.length-1];
last.next = handler;
}
handlers.push(handler);
}
const firstUnary = new unaryCall(null);
firstPart = (new Proxy(unaryCall, handlers[0])).bind(firstUnary);
Creating a unaryCall
didn't really need a parameter, but I put it in there to satisfy whatever goes on in my brain.
You can see at the top of the loop, I iterate through all the parameters of the function by using the length of the function. You can't actually access the parameters in the definition, so this is as close as it gets.
That's the bulk of it. You can see the entirety of the function in this gist
Let me know your thoughts and any questions in the comments below!
* Sadly I can no longer enjoy spicy hot curry due to a recently acquired medical condition, so my enjoyment is more hypothetical these days.
Posted on August 11, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.