This post will cover a technique for implementing getters and setters for every item in an array, using Proxies.

A common issue with getters and setters is that they are not recursive for nested objects or arrays. This can cause confusion when accessing an object via a getter and attempting to mutate a field on the returned object and not seeing the changes take effect.

Here is an example of the issue. If we have a class that stores its data in an ArrayBuffer instead of just plain variables, then the issue becomes easier to understand.

This example just stores an RGBa color in the buffer as 4 u8’s.

class Foo {
  view = new DataView(new ArrayBuffer(4))

  get color() {
    return [
      this.view.getUint8(0), // red
      this.view.getUint8(1), // green
      this.view.getUint8(2), // blue
      this.view.getUint8(3), // alpha
    ]
  }

  set color(value) {
    this.view.setUint8(0, value[0])
    this.view.setUint8(1, value[1])
    this.view.setUint8(2, value[2])
    this.view.setUint8(3, value[3])
  }
}

Accessing and setting the entire color will always work as expected:

const foo = new Foo()

foo.color = [1, 6, 8, 4]

console.log(foo.color) // [1, 6, 8, 4] - it works!

So we see that assigning and getting the entire array works as expected, the issue comes when trying to access and then mutate individual items in the array:

const foo = new Foo()

foo.color = [1, 6, 8, 4]

foo.color[0] = 100

console.log(foo.color) // [1, 6, 8, 4] - Wrong! not updated correctly

We see above that when logging the color again we don’t see the first element in the array being 100 which is unexpected - it should’ve been mutated.

Why does this happen?

When attempting to set an indexed item in an array returned from a getter like this foo.color[0] = 100 what is happening is that foo.color calls the getter which returns a new array with the correct values, [1, 6, 8, 4], and mutating that array works, so it becomes [100, 6, 8, 4], but the changes do not get propagated to the underlying array buffer because the array indices are not connected via getters or setters!

So when logging out the underlying data again using the getter, we still only see the old data in the buffer…

How do we fix this?

For nested objects, in most cases, we can use nested getters and setters to allow the mutations on a per-field basis. But for arrays, it is more difficult to define getters and setters on a per-index basis, and using tools like Object.defineProperty(...) can get messy and hard to extend to Typescript.

So how do we solve this for arrays returned from getters?

In comes Proxy! Proxies are a way to intercept actions performed on objects or arrays - they can see when something tries to get or set a field and change either the return value, or how the data is set completely.

To fix our example, we can modify the color getter to return a Proxied array instead of a normal array, so we intercept mutations on individual items and set them accordingly.

class Foo {
  view = new DataView(new ArrayBuffer(4))

  get color() {
    const self = this

    return new Proxy([
      this.view.getUint8(0),
      this.view.getUint8(1),
      this.view.getUint8(2),
      this.view.getUint8(3)
    ], {
      set(_, prop, value) {
        self.view.setUint8(parseInt(prop), value)
        return true
      }
    })
  }

  set color(value) {
    this.view.setUint8(0, value[0])
    this.view.setUint8(1, value[1])
    this.view.setUint8(2, value[2])
    this.view.setUint8(3, value[3])
  }
}

Now let’s try mutating individual items again.

const foo = new Foo()

foo.color = [1, 6, 8, 4]

foo.color[0] = 100

console.log(foo.color) // [100, 6, 8, 4] - it works!!

Now consumers of this API can safely mutate the data received from the getters with no issues.

Drawbacks

One drawback to returning a Proxied object from a getter is when logging the entire object to the console, the type of the object is wrapped in the Proxy instance.

So a normal console log of an unproxied array would be printed like this:

console.log(foo.color)
// [1, 6, 8, 4]

but if it’s proxied it would print like:

console.log(foo.color)
// firefox
// { <target>: [1, 6, 8, 4], <handler>: {...} }

// chrome
// { [[Target]]: [1, 6, 8, 4], [[Handler]]: {...}, [[IsRevoked]]: false }

This may confuse users, but it will behave exactly like a normal array.

I hope this post was helpful in understanding how to allow mutations on an array returned from a getter. Go forth and Proxy!