Solving the Array Problem
At the beginning of this chapter, I explained how developers couldn’t mimic the behavior of an array accurately in JavaScript prior to ECMAScript 6. Proxies and the reflection API allow you to create an object that behaves in the same manner as the built-in Array
type when properties are added and removed. To refresh your memory, here’s an example showing the behavior that proxies help to mimick:
let colors = ["red", "green", "blue"];
console.log(colors.length); // 3
colors[3] = "black";
console.log(colors.length); // 4
console.log(colors[3]); // "black"
colors.length = 2;
console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"
There are two particularly important behaviors to notice in this example:
- The
length
property is increased to 4 whencolors[3]
is assigned a value. - The last two items in the array are deleted when the
length
property is set to 2.
These two behaviors are the only ones that need to be mimicked to accurately recreate how built-in arrays work. The next few sections describe how to make an object that correctly mimics them.
Detecting Array Indices
Keep in mind that assigning to an integer property key is a special case for arrays, as those are treated differently from non-integer keys. The ECMAScript 6 specification gives these instructions on how to determine if a property key is an array index:
A String property name
P
is an array index if and only ifToString(ToUint32(P))
is equal toP
andToUint32(P)
is not equal to 2^32^-1.
This operation can be implemented in JavaScript as follows:
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
The toUint32()
function converts a given value into an unsigned 32-bit integer using an algorithm described in the specification. The isArrayIndex()
function first converts the key into a uint32 and then performs the comparisons to determine if the key is an array index or not. With these utility functions available, you can start to implement an object that will mimic a built-in array.
Increasing length when Adding New Elements
You might have noticed that both array behaviors I described rely on the assignment of a property. That means you really only need to use the set
proxy trap to accomplish both behaviors. To get started, here’s an example that implements the first of the two behaviors by incrementing the length
property when an array index larger than length - 1
is used:
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
function createMyArray(length=0) {
return new Proxy({ length }, {
set(trapTarget, key, value) {
let currentLength = Reflect.get(trapTarget, "length");
// the special case
if (isArrayIndex(key)) {
let numericKey = Number(key);
if (numericKey >= currentLength) {
Reflect.set(trapTarget, "length", numericKey + 1);
}
}
// always do this regardless of key type
return Reflect.set(trapTarget, key, value);
}
});
}
let colors = createMyArray(3);
console.log(colors.length); // 3
colors[0] = "red";
colors[1] = "green";
colors[2] = "blue";
console.log(colors.length); // 3
colors[3] = "black";
console.log(colors.length); // 4
console.log(colors[3]); // "black"
This example uses the set
proxy trap to intercept the setting of an array index. If the key is an array index, then it is converted into a number because keys are always passed as strings. Next, if that numeric value is greater than or equal to the current length
property, then the length
property is updated to be one more than the numeric key (setting an item in position 3 means the length
must be 4). After that, the default behavior for setting a property is used via Reflect.set()
, since you do want the property to receive the value as specified.
The initial custom array is created by calling createMyArray()
with a length
of 3 and the values for those three items are added immediately afterward. The length
property correctly remains 3 until the value "black"
is assigned to position 3. At that point, length
is set to 4.
With the first behavior working, it’s time to move on to the second.
Deleting Elements on Reducing length
The first array behavior to mimic is used only when an array index is greater than or equal to the length
property. The second behavior does the opposite and removes array items when the length
property is set to a smaller value than it previously contained. That involves not only changing the length
property, but also deleting all items that might otherwise exist. For instance, if an array with a length
of 4 has length
set to 2, the items in positions 2 and 3 are deleted. You can accomplish this inside the set
proxy trap alongside the first behavior. Here’s the previous example again, with an updated createMyArray
method:
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
function createMyArray(length=0) {
return new Proxy({ length }, {
set(trapTarget, key, value) {
let currentLength = Reflect.get(trapTarget, "length");
// the special case
if (isArrayIndex(key)) {
let numericKey = Number(key);
if (numericKey >= currentLength) {
Reflect.set(trapTarget, "length", numericKey + 1);
}
} else if (key === "length") {
if (value < currentLength) {
for (let index = currentLength - 1; index >= value; index--) {
Reflect.deleteProperty(trapTarget, index);
}
}
}
// always do this regardless of key type
return Reflect.set(trapTarget, key, value);
}
});
}
let colors = createMyArray(3);
console.log(colors.length); // 3
colors[0] = "red";
colors[1] = "green";
colors[2] = "blue";
colors[3] = "black";
console.log(colors.length); // 4
colors.length = 2;
console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"
console.log(colors[0]); // "red"
The set
proxy trap in this code checks to see if key
is "length"
in order to adjust the rest of the object correctly. When that happens, the current length is first retrieved using Reflect.get()
and compared against the new value. If the new value is less than the current length, then a for
loop deletes all properties on the target that should no longer be available. The for
loop goes backward from the current array length (currentLength
) and deletes each property until it reaches the new array length (value
).
This example adds four colors to colors
and then sets the length
property to 2. That effectively removes the items in positions 2 and 3, so they now return undefined
when you attempt to access them. The length
property is correctly set to 2 and the items in positions 0 and 1 are still accessible.
With both behaviors implemented, you can easily create an object that mimics the behavior of built-in arrays. But doing so with a function isn’t as desirable as creating a class to encapsulate this behavior, so the next step is to implement this functionality as a class.
Implementing the MyArray Class
The simplest way to create a class that uses a proxy is to define the class as usual and then return a proxy from the constructor. That way, the object returned when a class is instantiated will be the proxy instead of the instance. (The instance is the value of this
inside the constructor.) The instance becomes the target of the proxy and the proxy is returned as if it were the instance. The instance will be completely private and you won’t be able to access it directly, though you’ll be able to access it indirectly through the proxy.
Here’s a simple example of returning a proxy from a class constructor:
class Thing {
constructor() {
return new Proxy(this, {});
}
}
let myThing = new Thing();
console.log(myThing instanceof Thing); // true
In this example, the class Thing
returns a proxy from its constructor. The proxy target is this
and the proxy is returned from the constructor. That means myThing
is actually a proxy even though it was created by calling the Thing
constructor. Because proxies pass through their behavior to their targets, myThing
is still considered an instance of Thing
, making the proxy completely transparent to anyone using the Thing
class.
With that in mind, creating a custom array class using a proxy in relatively straightforward. The code is mostly the same as the code in the “Deleting Elements on Reducing Length” section. The same proxy code is used, but this time, it’s inside a class constructor. Here’s the complete example:
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
class MyArray {
constructor(length=0) {
this.length = length;
return new Proxy(this, {
set(trapTarget, key, value) {
let currentLength = Reflect.get(trapTarget, "length");
// the special case
if (isArrayIndex(key)) {
let numericKey = Number(key);
if (numericKey >= currentLength) {
Reflect.set(trapTarget, "length", numericKey + 1);
}
} else if (key === "length") {
if (value < currentLength) {
for (let index = currentLength - 1; index >= value; index--) {
Reflect.deleteProperty(trapTarget, index);
}
}
}
// always do this regardless of key type
return Reflect.set(trapTarget, key, value);
}
});
}
}
let colors = new MyArray(3);
console.log(colors instanceof MyArray); // true
console.log(colors.length); // 3
colors[0] = "red";
colors[1] = "green";
colors[2] = "blue";
colors[3] = "black";
console.log(colors.length); // 4
colors.length = 2;
console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"
console.log(colors[0]); // "red"
This code creates a MyArray
class that returns a proxy from its constructor. The length
property is added in the constructor (initialized to either the value that is passed in or to a default value of 0) and then a proxy is created and returned. This gives the colors
variable the appearance of being just an instance of MyArray
and implements both of the key array behaviors.
Although returning a proxy from a class constructor is easy, it does mean that a new proxy is created for every instance. There is, however, a way to have all instances share one proxy: you can use the proxy as a prototype.