Extending arrays using proxies in JavaScript

Published 4/1/2019

In the last post we were looking at subclassing arrays. This time let's check out another feature ES6 brought to the table. Proxies!

We will continue in the spirit of test driven development. If you haven't read the previous post, we basically installed mocha and chai for testing and have a src as well as a test folder.

The goal is to have dynamic method names on our arrays so we can basically integrate any library.

Let me introduce to you roxy. You can find the GitHub here.

Before I even explain what proxies are, let's take a look at the test so you have a better picture of what we are trying to achieve.

Be aware that due to the nature of proxies, they are not polyfillable.

In the last post we were integrating the pluck method first. In case you are unfamiliar with it, here is the ES6 way of doing it

const locations = [
  { city: 'Tokyo' },
  { city: 'Naha' },
]

// pluck in ES6
const cities = locations.map(loc => loc.city)
cities //? [ 'Tokyo', 'Naha']

The goal was to turn locations.map(loc => loc.city) into locations.pluck('city').

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

To help us achieve the goal and add many other useful methods at the same time, let's integrate lodash.

In order to pluck using lodash you use lodash's own map method. If you pass a string instead of a function as the second argument, that string becomes the key it will pluck.

_.map(locations, 'city')

So here are the tests

const expect = require('chai').expect
const { proxy } = require('../src/roxy')

describe('proxy', function() {
    it('should be able to access Array.prototype methods', function() {
        const numbers = proxy([1, 2, 3])
        numbers.copyWithin(0, 1, 2) // lodash doesn't have `copyWithin`
        expect(numbers).to.deep.equal([ 2, 2, 3 ])
    })

    it('should pluck using lodash method', function() {
        const numbers = proxy([
            { id: 1 },
            { id: 2 },
            { id: 3 },
        ])

        const result = numbers.map('id')
        expect(result).to.deep.equal([ 1, 2, 3 ])
    })
})

That's pretty cool. We got rid of the new keyword and classes altogether. Let's check out roxy.js.

// let's only load the lodash methods we need
var lodashArray = require('lodash/array')
var lodashCollection = require('lodash/collection')

const proxySymbol = Symbol('isProxy')

function _transformResult(result) {
    if (Array.isArray(result) && !result[proxySymbol]) {
        return proxy(result)
    }
    return result
}

const libraryList = [lodashCollection, lodashArray]

function proxy(array) {
    const handler = {
        get: function get(target, prop) {
            const library = libraryList.find(library => typeof library[prop] === 'function')
            if (library) {
                return function(...args) {
                    const result = library[prop](this, ...args)
                    return _transformResult(result)
                }
            }

            if (typeof target[prop] !== 'undefined') {
                return target[prop]
            }

            if (prop === proxySymbol) {
                return true
            }
        }
    }
    
    return new Proxy(array, handler)
}

module.exports = {
    proxy,
}

Proxies are exactly what their name suggest. We could also say it is a gateway to the actual array. But the gateway is guarded by so called traps. In this case, whenever you access a property on the array, it will not actually access it, but it will get trapped in the get method of our handler, so let's go through it step by step to understand it.

const library = libraryList.find(library => typeof library[prop] === 'function')
if (library) {
    return function(...args) {
        const result = library[prop](this, ...args)
        return _transformResult(result)
    }
}

First, we check if the method is either inside lodash collections or lodash arrays.

If you access array.map it will find it inside lodashCollection and return a new function. The function already knows at that point that library[prop] will be lodashCollection.map.

Then, when you execute the function like this array.map('id'), it will take the arguments you passed and executes the lodash function together with the actual array as the first argument.

With _transformResult we will proxy the result again in case it is a normal array. This allows for better chaining.


if (typeof target[prop] !== 'undefined') {
    return target[prop]
}

Next, we want to check if the method is an existing property of the array and simply return it. This would be the case for accessing the length property or methods like copyWithin that don't exist in lodash.

if (prop === proxySymbol) {
    return true
}

This lets us know whether an array is a roxy instance or not. In _transformResult when we access result[proxySymbol] and result is already a roxy instance, it would get trapped in the get method and would return true at this point if (prop === proxySymbol). So in case the returned array is already a roxy instance, no need to proxy it again.

Check out the part of _transformResult again:

if (Array.isArray(result) && !result[proxySymbol]) {
    return proxy(result)
}
return result

We can check if _transformResult works by writing another test

it('should be able to chain lodash methods', function() {
    const locations = proxy([
        { location: {city: 1 } },
        { location: {city: 2 } },
        { location: {city: 3 } },
    ])

    const result = locations.map('location').map('city')
    expect(result).to.deep.equal([ 1, 2, 3 ])
})

The same way you can now use lodash's map method, you should be able to use a bunch more like chunk, keyBy, shuffle and so on.

Of course you don't have to use lodash. You can do something similar with any array library. But there is probably not a single library that would fulfill all your expecations. So let's also add a method to create custom macros.

The test

const { proxy, macro } = require('../src/roxy')

// ...

it('can use macros', function() {
    macro('stringify', (array, prepend) => prepend + JSON.stringify(array))

    const numbers = proxy([1, 2])
    const result = numbers.stringify('array: ')
    expect(result).to.equal('array: [1,2]')
})

For the implementation, we only have to do a couple things.

const macroMap = {}
const libraryList = [lodashCollection, lodashArray, macroMap]

function macro(name, fn) {
    macroMap[name] = fn
}

// ...

module.exports = {
    proxy,
    macro,
}

And that's all there is to it!

Conclusion

Proxies offer a wide range of new possibilities to explore. In fact the next major version of vue will use proxies for its reactivity system. This tutorial made only use of the get trap. There are actually many more like set, construct, has, etc. Check out the mdn references below to learn more about proxies.

References