Array Sub-Classing With Validated [index] = <value> Support

The desire to sub-class arrays and lack (or perceived lack) of JavaScript support for this capability has generated a fair number of articles addressing work-arounds (Google). We needed sub-classes tuned to vector and matrix math for Hypercalc (a headless, multi-dimensional spreadsheet) and were not content with the options we uncovered. This is primarily because type checking could not be enforced when setting values directly using [], or in some cases at all. Since Proxies provide the ability to almost completely control instance access, we decided to explore their use for the implementation of custom Array behavior.

In this article, a Vector class that limits itself to a single dimension of numbers is implemented.

The core of what we need to accomplish will work directly in the most recent versions of Chrome, Edge, and Firefox or can be transpiled with Babel to support older browsers:

class Vector extends Array {
constructor() { super(...arguments); }
concat() {
if([].slice.call(arguments).some(arg => Array.isArray(arg)
&& arg.some(nested => typeof(nested)!=="number"))) {
error("concat: Vectors can only only contain numbers!");
return;
}
return [].concat.call(this,...arguments);
}
}
// define push, unshift, splice so they reject non-numbers
["push","unshift","splice"].forEach(property => {
Vector.prototype[property] = function() {
if([].slice.call(arguments).some(arg => typeof(arg)!=="number") {
error(property + ": Vectors can only contain numbers!");
return;
}
return [].push.call(this,...arguments);
}
});

Note the mixed use of new style class syntax with the older style prototype syntax. This is a convenience we discovered during this development. In general, the class syntax is cleaner; however, the older style syntax can dramatically reduce boilerplate code.

The error function is a utility. The definition is left up to the user. It allows the consolidation of error logging with throwing and is over simplified in this example.

The above code works well until we decide to do something like this:

const vector = new Vector(3);
vector[2] = [1];

This code would successfully add a nested array to our Vector, which would be fine if we wanted a Matrix … but we don’t!

Proxies make it very easy to handle this situation with a set handler:

function Vector() {
const proxy = new Proxy(new Array(...arguments),{
get: (target,property) => {
// 1st see if target is enhanced with its own properties
let desc = Object.getOwnPropertyDescriptor(target,property);
if(desc) return desc.value;
// 2nd check Vector prototype which may shadow/enhance Array
desc = Object.getOwnPropertyDescriptor(Vector.prototype,property);
if(desc) return desc.value;
// 3rd, just do normal lookup
return target[property];
},
set: (target,property,value) => {
if(typeof(arg)!=="number") {
error("set: Vectors can only contain numbers!");
return;
}
target[property] = value;
return true;
},
deleteProperty: (target,property) => {
// create localized property that makes it look undefined
// causes the 1st get handler to respond with undefined
Object.defineProperty(target,property,{value:undefined});
return true;
}
});
// shadow any unwanted methods by deleting here
[].forEach(key => delete proxy[key]);
return proxy;
}
Vector.prototype = []; // give Vector all the capability of Array

As you can see set handles incorrect types in the same way as push, splice, unshift and concat. Additionally, a get handler has been added to ensure proper search order for requested properties. Finally, a deleteProperty handler has been added so that unwanted properties can be eliminated. If you are not comfortable with a fake undefined, you could implement a custom function that just throws an error or even add a lookup inside the get handler that returns undefined for certain properties.

Moving concat to a prototype property and adding the rest of the original prototype definition completes our example, which is also available on JSFiddle.

function Vector() {
const proxy = new Proxy(new Array(...arguments),{
get: (target,property) => {
// 1st see if target is enhanced with its own properties
let desc = Object.getOwnPropertyDescriptor(target,property);
if(desc) return desc.value;
// 2nd check Vector prototype which may shadow/enhance Array
desc = Object.getOwnPropertyDescriptor(Vector.prototype,property);
if(desc) return desc.value;
// 3rd, just do normal lookup
return target[property];
},
set: (target,property,value) => {
if(typeof(arg)!=="number") {
error("set: Vectors can only contain numbers!");
return;
}
target[property] = value;
return true;
},
deleteProperty: (target,property) => {
// create localized property that makes it look undefined
// causes the 1st get handler to respond with undefined
Object.defineProperty(target,property,{value:undefined});
return true;
}
});
// shadow any unwanted methods by deleting here
[].forEach(key => delete proxy[key]);
return proxy;
}
Vector.prototype = []; // give Vector all the capability of Array
// define push, unshift, splice so they reject nesting arrays
["push","unshift","splice"].forEach(property => {
Vector.prototype[property] = function() {
if([].slice.call(arguments).some(arg => Array.isArray(arg))) {
error(property + ": Vectors can only have one dimension!");
return;
}
return [].push.call(this,...arguments);
}
});
Vector.prototype.concat = function() {
if([].slice.call(arguments).some(arg => Array.isArray(arg)
&& arg.some(nested => Array.isArray(nested)))) {
error("concat: Vectors can only have one dimension!");
return;
}
return [].concat.call(this,...arguments);
}
Like what you read? Give AnyWhichWay a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.