Subclassing arrays in JavaScript

Published 3/31/2019

In my previous post I was showing how with various array methods we can reveal our intent. But in the end I was not trully satisfied with the result.

While

const usernames = users.map(user => user.name)

is definitely much more readable than

const usernames = []

users.forEach(user => {
  usernames.push(user.name)
})

wouldn't

const usernames = users.pluck('name')

be even nicer?

So let's see how we can create such functionality. Let's dive into the world of subclassing arrays. We will also look at unit testing in NodeJS as well as a more functional alternative approach.

Check out my e-book!

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

Btw. I am not promoting some revolutionary new library here. We are simply exploring ideas. I still created a GitHub repo for this so you can check out the whole code if you want.


But first, how do we create arrays in JavaScript?

The classic

const numbers = [1, 2, 3]

and the maybe not so well known

const numbers = new Array(1, 2, 3)

But the above doesn't do what you would expect when you only pass one argument. new Array(3) would create an array with three empty values instead of an array with just one value being 3.

ES6 introduces a static method that fixes that behaviour.

const numbers = Array.of(1, 2, 3)

Then there is also this

const array = Array.from({ length: 3 })
//? (3) [undefined, undefined, undefined]

The above works because Array.from expects an array-like object. An object with a length property is all we need to create such an object.

It can also have a second parameter to map over the array.

const array = Array.from({ length: 3 }, (val, i) => i)
//? (3) [0, 1, 2]

With that in mind, let's create Steray, Array on Steroids.

With ES6 and the introduction of classes it is possible to easily extend arrays

class Steray extends Array {
    log() {
        console.log(this)
    }
}

const numbers = new Steray(1, 2, 3)
numbers.log() // logs [1, 2, 3]

So far so good, but what if we have an existing array and want to turn it into a Steray?

Remember that with Array.from we can create a new array by passing an array-like object, and aren't arrays kind of included in that definition?

Which ultimately means we can do this

const normalArray = [1, 2, 3]
const steray = Steray.from(normalArray)

or alternatively

const normalArray = [1, 2, 3]
const steray = Steray.of(...normalArray)

Let's start adding some methods to Steray. Inside steray.js we can just add the long awaited pluck method to the class

pluck(key) {
    return this.map(item => item[key])
}

and that's it. Elegant and powerful.

Setting up tests

But how do we know this works? We don't know want to go into the browser every time and test our class in the console. So let's quickly set up unit testing, so we can be confident that what we are doing is correct.

Create the following directory structure

steray
    src
        steray.js
    test
        sterayTest.js

With node and npm installed, install the unit testing framework mocha globally.

npm install mocha -g

Next let's initialize package.json by running npm init in the root of the directory. Follow the instructions until it creates a package.json file. When it asks you for the test script enter mocha. Alternatively you can also change this later inside package.json.

"scripts": {
    "test": "mocha"
},

Next, install the assertion library chai locally

npm install chai --save-dev

And that's all we had to setup. Let's open up sterayTest.js and write our first test

const expect = require('chai').expect
const Steray = require('../src/steray')

describe('pluck', function() {
    it('should pluck values using the "name" prop', function() {
        const users = new Steray( 
            { name: 'Michael' },
            { name: 'Lukas' },
        )

        const names = users.pluck('name')
        expect(names).to.deep.equal([ 'Michael', 'Lukas' ])
    })
})

Run the tests using npm run test in the root of the directory and it should output that one test is passing. With that out of the way we can now safely continue writing new methods, or change the implementation of pluck without having to worry about our code breaking.

Let's add some more methods, but this time in the spirit of test driven development!


You know what I really don't like? These pesky for loops.

for (let i; i < 10; i++)

Is it let i or const i, is it < or <=? Wouldn't it be nice if there was an easier way to achieve this. While you can use the syntax we learned earlier Array.from({ length: 10 }, (value, index) => index) it is unnecessarily verbose. Inspired by lodash and Laravel collections, let's create a static times method.

In order for you to see the method in action, let's first create the unit test.

describe('times', function() {
    it('should return an array containing the indices 0 and 1', function() {
        const numbers = Steray.times(2, i => i)
        expect(numbers).to.deep.equal([ 0, 1 ])
    })
})

Try running npm run test and it should return errors because times doesn't exist yet.

I will always show the test first, so you can try implementing the method yourself before looking at my implementation. Found a better solution? Send in a PR!

So, here is my implementation of times in steray.js

static times(length, fn) {
    return this.from({ length }, (value, i) => fn(i))
}

Sometimes you might get confused if there is a long chain and you want to tap into the process to see what is going on. So let's build that functionality.

An example use case would be

[1, 2, 3, 4, 5]
    .filter(i => i < 4)
    .map(i => i * 10)
    .tap(console.log)
    .find(i => i === 20)

tap executes the function but then just returns the very same array again unmodified. tap does not return what the callback returns. For such a functionality, let's create another method pipe.

Here are the tests

describe('tapping and piping', function() {
    it('should execute callback one time', function() {
        let i = 0
        new Steray(1, 2, 3).tap(array => i = i + 1)

        expect(i).to.equal(1)
    })

    it('should return original array when tapping', function() {
        const array = new Steray(1, 2, 3).tap(() => 10)
        expect(array).to.deep.equal([1, 2, 3])
    })

    it('should return result of pipe', function() {
        const piped = new Steray(1, 2, 3).pipe(array => array.length)
        expect(piped).to.equal(3)
    })
})

And here is the implementation

tap(fn) {
    fn(this)
    return this
}
pipe(fn) {
    return fn(this)
}

It's amazing how small yet powerful these methods are!


Remember how in the previous blog post we were turning the users array into a hashMap grouped by the group key.

Let's also create this functionality by implementing a new method groupBy! Here is the test

describe('groupBy', function() {
    it('should hashMap', function() {
        const users = new Steray( 
            { name: 'Michael', group: 1 },
            { name: 'Lukas', group: 1 },
            { name: 'Travis', group: 2 },
        )

        const userMap = users.groupBy('group')

        expect(userMap).to.deep.equal({
            '1': [
                { name: 'Michael', group: 1 },
                { name: 'Lukas', group: 1 },
            ],
            '2': [
                { name: 'Travis', group: 2 },
            ]
        })
    })
})

and here is the implementation

groupBy(groupByProp) {
    return this.reduce((result, item) => {
        const id = item[groupByProp]
        result[id] = result[id] || new []
        
        result[id].push(rest);

        return result;
    }, {})
}

While this works, we might run into problems at one point. I will add another unit test to illustrate what can go wrong.

it('should hashMap using Steray array', function() {
    const users = new Steray( 
        { name: 'Michael', group: 1 },
        { name: 'Lukas', group: 1 },
        { name: 'Travis', group: 2 },
    )

    const userMap = users.groupBy('group')
    const groupOne = userMap['1']
    const isInstanceOfSteray = (groupOne instanceof Steray)
    expect(isInstanceOfSteray).to.be.true
})

What went wrong is result[id] = result[id] || [], specifically []. Since we create a normal array, all our newly implemented methods will not be available.

To fix this, let's use result[id] = result[id] || new Steray instead.

While the test will pass, the solution is also not 100% clean. What if we later wanted to move this function into its own file and import it here, wouldn't it create circular dependencies? Also it would be nice if it would be unaware of Steray.

A better solution in my opinion is the following

result[id] = result[id] || new this.constructor

this refers to the steray array and with this.constructor we get the class Steray dynamically.


There is a lot more we can add really

  • deduplicating
  • chunking
  • padding
  • prepending data to an array without transforming the original array (unlike unshift)

just to name a few.

You can find the Steray class including the unit tests and the above mentioned methods like chunk, pad, unique and prepend in the following GitHub repo.

An alternative to subclassing

Eventually our class may grow into a massive clutter of helper functions and you might run into certain limits.

A different approach would be to go completely functional with ramda. Ramda has the extra benefit that it also has methods for objects, strings, numbers, even functions.

An example of ramda would be

const R = require('ramda')

const users = [
    { name: 'Conan', location: { city: 'Tokyo' } },
    { name: 'Genta', location: { city: 'Tokyo' } },
    { name: 'Ayumi', location: { city: 'Kawasaki' } },
]

const getUniqueCitiesCapitalized = R.pipe(
    R.pluck('location'),
    R.pluck('city'),
    R.map(city => city.toUpperCase()),
    R.uniq()
)
const cities = getUniqueCitiesCapitalized(users)

expect(cities).to.deep.equal(['TOKYO', 'KAWASAKI'])

So how about we combine the two, a simple array subclass with the power of consuming ramda functions. I know I know, we are sort of abusing ramda at this point, but it's still interesting to check it out. We just need a new name..., our Array class is not really on steroids anymore, it's quite the opposite, so let' call it Yaseta, the Japanese expression when somebody lost weight.

Let's install ramda using npm install ramda --save-dev (we only need it for the tests) and create some tests, so we can see how we will use our new library.

// test/yasetaTest.js

const expect = require('chai').expect
const Yaseta = require('../src/yaseta')
const pluck = require('ramda/src/pluck')

describe('underscore methods', function() {
    it('returns result of callback', function() {
        const numbers = new Yaseta(1, 2)
        const size = numbers._(array => array.length)

        expect(size).to.equal(2)
    })

    it('returns result of assigned callback using higher order function', function() {
        const users = new Yaseta(
            { name: 'Conan' },
            { name: 'Genta' }
        )

        // this is how ramda works
        const customPluck = key => array => {
            return array.map(item => item[key])
        }

        const usernames = users._(customPluck('name'))

        expect(usernames).to.deep.equal(['Conan', 'Genta'])
    })

    it('can assign ramda methods', function() {
        const users = new Yaseta(
            { name: 'Conan' },
            { name: 'Genta' }
        )

        const usernames = users._(pluck('name'))

        expect(usernames).to.deep.equal(['Conan', 'Genta'])
    })
})

And let's create yaseta.js in the src directory.

class Yaseta extends Array {
    _(fn) {
        const result = fn(this)
        return this._transformResult(result)
    }

    _transformResult(result) {
        if (Array.isArray(result)) {
            return this.constructor.from(result)
        }

        return result
    }
}

module.exports = Steray

We called the method _ to take the least amount of space by still providing some readability (at least for people familiar with lodash and such). Well, we are just exploring ideas here anyways.

But what's the deal with _transformResult?

See when ramda creates new arrays it doesn't do it using array.constructor. It just creates a normal array, I guess this is because their list functions also work on other iterables. So we would not be able to say

numbers
    ._(array => array)
    ._(array => array) // would crash here

But thanks to _transformResult, we turn it into a Yaseta instance again. Let's add another test to see if the above is possible

const pluck = require('ramda/src/pluck')
const uniq = require('ramda/src/uniq')
const map = require('ramda/src/map')
// ...
it('can chain methods with ramda', function() {
    const users = new Yaseta(
        { name: 'Conan', location: { city: 'Tokyo' } },
        { name: 'Genta', location: { city: 'Tokyo' } },
        { name: 'Ayumi', location: { city: 'Kanagawa' } },
    )

    const cities = users
        ._(pluck('location'))
        ._(pluck('city'))
        .map(city => city.toUpperCase())
        ._(map(city => city.toUpperCase())) // same as above
        .filter(city => city.startsWith('T'))
        ._(uniq)

        expect(cities).to.deep.equal(['TOKYO'])
})

Let's also create a pipe method. This time, you can pass as many functions as you need though.

describe('pipe', function() {
    it('can pipe methods', function() {
        const users = new Yaseta(
            { name: 'Conan', location: { city: 'Tokyo' } },
            { name: 'Genta', location: { city: 'Tokyo' } },
            { name: 'Ayumi', location: { city: 'Kanagawa' } },
        )

        const cities = users
            .pipe(
                pluck('location'),
                pluck('city'),
                map(city => city.toUpperCase())
            )
            .filter(city => city.startsWith('T'))
            ._(uniq)

            expect(cities).to.deep.equal(['TOKYO'])
    })
})

And the implementation in the Yaseta class:

pipe(...fns) {
    const result = fns.reduce((result, fn) => {
        return fn(result)
    }, this)

    return this._transformResult(result)
}

Conclusion

So when we compare the different solutions, what do we have now?

Steray

users = Steray.from(users)
const usernames = users.pluck('name')

Yaseta

users = Yaseta.from(users)
const usernames = users._(pluck('name'))

ramda

const usernames = R.pluck('name')(users)

Vanilla

const usernames = users.map(user => user.name)

Each has its own benefits

Steray

[+] super readable

[-] subclassing array necessary

[-] manually define methods on class

Yaseta

[+] can use all of ramdas methods, but not limited to ramda

[+] OSS contributors could also add more functions that you can install separately.

[-] subclassing array necessary

[-] underscore might throw some off

ramda

[+] provides 100% functional approach

[-] We can no longer use dot notation and the Array.prototype methods

Vanilla

[+] can be used anywhere

[+] no additional learning required for devs

[-] limited to existing methods


In most cases the vanilla version is probably good enough, but it's nontheless interesting to see what is possible in JavaScript.

It turns out there is actually another way of handling this kind of thing. Wouldn't it be nice if we could just have dynamic method names on our arrays? Turns out we can!

But that's for next time ;)