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:

  1. let colors = ["red", "green", "blue"];
  2. console.log(colors.length); // 3
  3. colors[3] = "black";
  4. console.log(colors.length); // 4
  5. console.log(colors[3]); // "black"
  6. colors.length = 2;
  7. console.log(colors.length); // 2
  8. console.log(colors[3]); // undefined
  9. console.log(colors[2]); // undefined
  10. console.log(colors[1]); // "green"

There are two particularly important behaviors to notice in this example:

  1. The length property is increased to 4 when colors[3] is assigned a value.
  2. 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 if ToString(ToUint32(P)) is equal to P and ToUint32(P) is not equal to 2^32^-1.

This operation can be implemented in JavaScript as follows:

  1. function toUint32(value) {
  2. return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
  3. }
  4. function isArrayIndex(key) {
  5. let numericKey = toUint32(key);
  6. return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
  7. }

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:

  1. function toUint32(value) {
  2. return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
  3. }
  4. function isArrayIndex(key) {
  5. let numericKey = toUint32(key);
  6. return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
  7. }
  8. function createMyArray(length=0) {
  9. return new Proxy({ length }, {
  10. set(trapTarget, key, value) {
  11. let currentLength = Reflect.get(trapTarget, "length");
  12. // the special case
  13. if (isArrayIndex(key)) {
  14. let numericKey = Number(key);
  15. if (numericKey >= currentLength) {
  16. Reflect.set(trapTarget, "length", numericKey + 1);
  17. }
  18. }
  19. // always do this regardless of key type
  20. return Reflect.set(trapTarget, key, value);
  21. }
  22. });
  23. }
  24. let colors = createMyArray(3);
  25. console.log(colors.length); // 3
  26. colors[0] = "red";
  27. colors[1] = "green";
  28. colors[2] = "blue";
  29. console.log(colors.length); // 3
  30. colors[3] = "black";
  31. console.log(colors.length); // 4
  32. 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:

  1. function toUint32(value) {
  2. return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
  3. }
  4. function isArrayIndex(key) {
  5. let numericKey = toUint32(key);
  6. return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
  7. }
  8. function createMyArray(length=0) {
  9. return new Proxy({ length }, {
  10. set(trapTarget, key, value) {
  11. let currentLength = Reflect.get(trapTarget, "length");
  12. // the special case
  13. if (isArrayIndex(key)) {
  14. let numericKey = Number(key);
  15. if (numericKey >= currentLength) {
  16. Reflect.set(trapTarget, "length", numericKey + 1);
  17. }
  18. } else if (key === "length") {
  19. if (value < currentLength) {
  20. for (let index = currentLength - 1; index >= value; index--) {
  21. Reflect.deleteProperty(trapTarget, index);
  22. }
  23. }
  24. }
  25. // always do this regardless of key type
  26. return Reflect.set(trapTarget, key, value);
  27. }
  28. });
  29. }
  30. let colors = createMyArray(3);
  31. console.log(colors.length); // 3
  32. colors[0] = "red";
  33. colors[1] = "green";
  34. colors[2] = "blue";
  35. colors[3] = "black";
  36. console.log(colors.length); // 4
  37. colors.length = 2;
  38. console.log(colors.length); // 2
  39. console.log(colors[3]); // undefined
  40. console.log(colors[2]); // undefined
  41. console.log(colors[1]); // "green"
  42. 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:

  1. class Thing {
  2. constructor() {
  3. return new Proxy(this, {});
  4. }
  5. }
  6. let myThing = new Thing();
  7. 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:

  1. function toUint32(value) {
  2. return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
  3. }
  4. function isArrayIndex(key) {
  5. let numericKey = toUint32(key);
  6. return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
  7. }
  8. class MyArray {
  9. constructor(length=0) {
  10. this.length = length;
  11. return new Proxy(this, {
  12. set(trapTarget, key, value) {
  13. let currentLength = Reflect.get(trapTarget, "length");
  14. // the special case
  15. if (isArrayIndex(key)) {
  16. let numericKey = Number(key);
  17. if (numericKey >= currentLength) {
  18. Reflect.set(trapTarget, "length", numericKey + 1);
  19. }
  20. } else if (key === "length") {
  21. if (value < currentLength) {
  22. for (let index = currentLength - 1; index >= value; index--) {
  23. Reflect.deleteProperty(trapTarget, index);
  24. }
  25. }
  26. }
  27. // always do this regardless of key type
  28. return Reflect.set(trapTarget, key, value);
  29. }
  30. });
  31. }
  32. }
  33. let colors = new MyArray(3);
  34. console.log(colors instanceof MyArray); // true
  35. console.log(colors.length); // 3
  36. colors[0] = "red";
  37. colors[1] = "green";
  38. colors[2] = "blue";
  39. colors[3] = "black";
  40. console.log(colors.length); // 4
  41. colors.length = 2;
  42. console.log(colors.length); // 2
  43. console.log(colors[3]); // undefined
  44. console.log(colors[2]); // undefined
  45. console.log(colors[1]); // "green"
  46. 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.