kentnek tech blog

Deferred Computation, Placeholders and Proxies (Part 2)

Previously, we implemented placeholders like _.some.random.property by converting properties into a list ["some", "random", "property"]. Then with the magic of Proxy’s handler.get, we can seemingly read non-existing properties from any object.

This time, we’ll add support for function calls, enabling expressions such as _.some.string.toUpperCase().

Approach 2 : Back to closures

At the beginning of the previous post, we tried deferring an operation by wrapping it around a closure, so _.some.property can be rewritten as input => input.some.property.

If we think about this, the placeholder’s evaluate $ method is actually similar. For example:

_.a.b[$] = input => input.a.b

We can extend this idea to support function calls like _.a.b.split(','):

_.a.b = {
    [$]: input => input.a.b,
    
    // split() returns another placeholder
    split: separator => {
        return {
            [$]: input => input.a.b.split(separator)    
        };
    }
}

So far, we have delayed property access (_.a.b.) by returning new placeholders. Now, for functions like split, we also generate another placeholder, effectively delaying the function call. Note that the parameter separator is passed to the inner $ closure.

A placeholder prototype

Here’s the JSFiddle playground for this section.

Suppose we know beforehand the structure of our data:

const data = {
    a: {
        b: "alice,bob,carol"
    }
};

Ultimately, resolve(_a.b.split(',')[1], data) should return 'bob'. To pass this test, we can cheat a little bit by unfolding our placeholder manually:

function createPlaceholder() {
    return {
        [$]: input => input,
        a: {
            [$]: input => input.a,
            b: {
                [$]: input => input.a.b,
                split: separator => ({
                    [$]: input => input.a.b.split(separator),
                    '1': {
                        [$]: input => input.a.b.split(separator)[1]
                    }
                })
            }
        }
    };
}
// note that '() => ({...})' is a shorthand for
// () => { return {...}; }

This fake placeholder satisfies all tests for _, _.a, _.a.b, _.a.b.split(',') and _.a.b.split(',')[1]. (Can you convince yourself why?)

Prototype placeholder!

The sharp-eyed reader might have also noticed a subtle recursive pattern. In fact, resolve(_.a.b, data) can recursively call resolve(_.a, data) then get b from the result:

resolve(_.a.b, data) === resolve(_.a, data).b 
/* or */
_.a.b[$](data) === _.a[$](data).b

And:

resolve(_.a.b.split(','), data) === resolve(_.a.b, data).split(',') 
/* or */
_.a.b.split(',')[$](data) === _.a.b[$](data).split(',')

Intuitively, a placeholder can utilize its parent’s $ function to resolve itself:

/* pseudo-code, due to usage of PARENT */
function createPlaceholder() {
    return {
        [$]: input => input, // base case, no PARENT
        a: {
            [$]: input => PARENT[$](input).a,
            b: {
                [$]: input => PARENT[$](input).b,
                split: separator => ({
                    [$]: input => PARENT[$](input).split(separator),
                    '1': {
                        [$]: input => PARENT[$](input)[1]
                    }
                })
            }
        }
    };
}

Writing that ourselves is really tiring, so we’d like to use recursion and Proxy to generate this structure automatically.

The second attempt

Let’s apply recursion on $ in our createPlaceholder as well

function createPlaceholder(parent, currentKey) {
    return new Proxy({
        // returns 'input' if 'parent' is undefined,
        // otherwise evaluates 'PARENT[$](input)' and extracts 'currentKey'
        [$]: (input) => parent ? parent[$](input)[currentKey] : input
    }, {
        get(target, key) {
            return key === $ ? target[$] : createPlaceholder(target, key);
        }
    });
}

This looks very much alike our first attempt. The main difference is how $ is defined recursively, and that a placeholder keeps a reference to its parent via createPlaceholder’s first parameter.

The implementation above works perfectly for property access. Why not try it naively for split?

let split = resolve(_.a.b.split, data);
console.log(split); // [Function: split]

console.log(split(','));
/* TypeError: String.prototype.split called on null or undefined */

Uh oh. We need to bind the split function to the string "alice,bob,carol" itself before we’re able to call it.

We can solve this easily by binding the child’s resolved value to the parent if it’s a function:

function createPlaceholder(parent, currentKey) {
    return new Proxy({
        [$]: (input) => {
            if (!parent) return input; // base case, no parent
            
            let resolvedParent = parent[$](input);
            let resolvedChild = resolvedParent[currentKey];
            if (typeof resolvedChild === "function") {
                resolvedChild = resolvedChild.bind(resolvedParent);
            }
            
            return resolvedChild;
        }
    }, {
        get(target, key) {
            return key === $ ? target[$] : createPlaceholder(target, key);
        }
    });
}

Then, our native attempt to use split works. Notice that our function is now bound:

let split = resolve(_.a.b.split, data);
console.log(split);         // [Function: bound split]
console.log(split(','));    // [ 'alice', 'bob', 'carol' ]

Although we can extract split out and call it normally, we want to apply the argument ',' in place: _.a.b.split(','). Similar to property access, we need to catch the placeholder’s function call events too.

An enhanced Proxy

Of course, the good people designing ES6 Proxy are generous enough to give us exactly what we need, which is the apply handler. It’s triggered when the target is called as a function with arguments args:

Proxy({...}, {
    apply(target, thisObj, args) {
        // ...
    }
});

That brings us the last piece of the puzzle:

function createPlaceholder(parent, currentKey, args) {
    return new Proxy({
        [$]: (input) => {
            if (!parent) return input; // base case, no parent
            
            let resolvedParent = parent[$](input);
            
            if (currentKey) {
                let resolvedChild = resolvedParent[currentKey];
                if (typeof resolvedChild === "function") {
                    resolvedChild = resolvedChild.bind(resolvedParent);
                }
                
                return resolvedChild;
            } else {
                return resolvedParent(...args);
            }
        }
    }, {
        get(target, key) {
            return key === $ ? target[$] : createPlaceholder(target, key);
        },
        
        apply(target, thisObj, args) {
            return createPlaceholder(target, null, args);
        }
    });
}

Here, _.a.b.split(',') will trigger the handler

apply(_.a.b.split, null, [','])

which creates a new placeholder with currentKey = null.

In $, since currentKey === null, we know that parent must be a function, and we apply it with the arguments args.

return resolvedParent(...args);

This should work impeccably.

TypeError: _.a.b.split is not a function

WTF?

Indeed, we are returning a Proxy wrapping around an Object, so the placeholder _.a.b.split is clearly not a function, and therefore not callable.

The final placeholder

The final trick is to wrap Proxy around a real function, and assign the $ method as a property of the function being wrapped:

function createPlaceholder(parent, currentKey, args) {
    const func = () => {}; // empty function
    
    func[$] = (input) => {
        if (!parent) return input; // base case, no parent

        let resolvedParent = parent[$](input);

        if (currentKey) {
            let resolvedChild = resolvedParent[currentKey];
            if (typeof resolvedChild === "function") {
                resolvedChild = resolvedChild.bind(resolvedParent);
            }

            return resolvedChild;
        } else {
            return resolvedParent(...args);
        }
    };

    return new Proxy(func, {
        get(target, key) {
            return key === $ ? target[$] : createPlaceholder(target, key);
        },

        apply(target, thisObj, args) {
            return createPlaceholder(target, null, args);
        }
    });
}

And we scored 5/5 for our test cases:

Final placeholder

Conclusion

Hopefully I’ve shown you that meta-programming in JavaScript is pretty cool, and ES6 Proxies have opened up a great deal of new possibilities for more powerful code manipulation.

If you have any other interesting application of Proxy or meta-programming in general, please comment below!