Subclassing arrays in JavaScript
Published 3/31/2019
This article is part of a series:
1 Array methods and iterables - Stepping up your JavaScript game
2 Explaining shallow / deep copying through acronyms
3 Subclassing arrays in JavaScript
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!
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 ;)