DEV Community

Michael Z
Michael Z

Posted on • Edited on • Originally published at michaelzanggl.com

Subclassing arrays in JavaScript

Originally posted at michaelzanggl.com. Subscribe to my newsletter to never miss out on new content.

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)
Enter fullscreen mode Exit fullscreen mode

is definitely much more readable than

const usernames = []

users.forEach(user => {
  usernames.push(user.name)
})
Enter fullscreen mode Exit fullscreen mode

wouldn't

const usernames = users.pluck('name')
Enter fullscreen mode Exit fullscreen mode

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.

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]
Enter fullscreen mode Exit fullscreen mode

and the maybe not so well known

const numbers = new Array(1, 2, 3)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Then there is also this

const array = Array.from({ length: 3 })
//? (3)Β [undefined, undefined, undefined]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

or alternatively

const normalArray = [1, 2, 3]
const steray = Steray.of(...normalArray)
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

npm install mocha -g
Enter fullscreen mode Exit fullscreen mode

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"
},
Enter fullscreen mode Exit fullscreen mode

Next, install the assertion library chai locally

npm install chai --save-dev
Enter fullscreen mode Exit fullscreen mode

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' ])
    })
})
Enter fullscreen mode Exit fullscreen mode

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++)
Enter fullscreen mode Exit fullscreen mode

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 ])
    })
})
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
    })
})
Enter fullscreen mode Exit fullscreen mode

And here is the implementation

tap(fn) {
    fn(this)
    return this
}
Enter fullscreen mode Exit fullscreen mode
pipe(fn) {
    return fn(this)
}
Enter fullscreen mode Exit fullscreen mode

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 },
            ]
        })
    })
})
Enter fullscreen mode Exit fullscreen mode

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;
    }, {})
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'])
Enter fullscreen mode Exit fullscreen mode

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'])
    })
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'])
})
Enter fullscreen mode Exit fullscreen mode

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'])
    })
})
Enter fullscreen mode Exit fullscreen mode

And the implementation in the Yaseta class:

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

    return this._transformResult(result)
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

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

Steray

users = Steray.from(users)
const usernames = users.pluck('name')
Enter fullscreen mode Exit fullscreen mode

Yaseta

users = Yaseta.from(users)
const usernames = users._(pluck('name'))
Enter fullscreen mode Exit fullscreen mode

ramda

const usernames = R.pluck('name')(users)
Enter fullscreen mode Exit fullscreen mode

Vanilla

const usernames = users.map(user => user.name)
Enter fullscreen mode Exit fullscreen mode

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 ;)

Top comments (1)

Collapse
 
austindd profile image
Austin

It should be noted that extending the Array to a custom ES6 class can DRAMATICALLY reduce performance.

I tested in this in jsperf, and most operations, from array construction to the inherited '.map()' method, were over 1,000x slower with the custom array class. The ops/sec can go from millions to thousands, which IMO is far from negligible.

You can see my tests here: jsperf.com/class-extends-array

I'm curious about why the performance gap is so massive. After several hours of research, I can only speculate the following:

(1) Standard host Array instances come with some guarantees that the browser's compiler can rely on to optimize their performance, and subclassed arrays cannot provide those guarantees.

(2) Altering the internal [[prototype]] of the custom array object can lead to severe performance costs, hinted at in the MDN docs here:

developer.mozilla.org/en-US/docs/W...

and here:

developer.mozilla.org/en-US/docs/W...