Simulating TypedArrays with ES6 proxies

Lager
4 min readAug 29, 2019

--

In our previous installment we learned about GrowableUint8Array, a class that makes it possible to efficiently append data to something that mostly behaves like a Uint8Array.

Unfortunately, one way in which it does not behave like a Uint8Array is with attribute access. To recap:

The problem is that, prior to ES6, there was no way to “intercept” attribute accesses. So arr[0] = 42; is actually setting an attribute on the GrowableUint8Array instance itself, rather than on the underlying Uint8Array.

So, how do we solve this problem? Luckily ES6 introduced a new class called Proxy that is well suited to our needs. Using this, we can intercept arbitrary attribute getters and setters, as well as other low-level class machinery: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy. A very basic implementation might look like this:

Here, we’re using the Reflect API, which provides default implementations of proxyable functions, to simply forward all attribute gets and sets to the buf instance variable of the GrowableUint8Array. Notice, however, that there's a problem - since we're unconditionally forwarding all attribute accesses to the buf member variable of the proxy target, we get an incorrect answer for arrayProxy.length - we get the length of the underlying Uint8Array, which does not account for the fact that some of those bytes are preallocated but unused.

What we actually want to do is only forward attribute accesses if they are array indices. So what is an array index? Surprisingly, it’s not simply any numeric attribute name. The ECMAScript Language Specification actually spells it out in detail:

A property name P (in the form of a String value) is an array index if and only if ToString(ToUint32(P)) is equal to P and ToUint32(P) is not equal to 2³²−1

Luckily, the abstract operation ToUint32 can be implemented with the unsigned right shift operator, if we shift right by zero. Armed with that knowledge, we can create an isArrayIndex function:

With that, we can implement an improved version of our proxy:

In this version, property names which are array indices are forwarded to the underlying buffer, keeping in mind that we should return undefined for indices which exceed the number of bytes actually in use by the buffer.

A more full-featured version of this has been implemented in GrowableUint8Array. Simply call .accessProxy() on an instance of GrowableUint8Array to get back a proxy which implements has, get, set, ownKeys, getOwnPropertyDescriptor, defineProperty, and deleteProperty. The result is an object which very closely mimics a true TypedArray(but see the Caveats section below). Let’s try using it in our contrived example from part 1:

It worked! Since the proxy has access to the target object (in this case a GrowableUint8Array instance) and not just an ephemeral Uint8Array instance, we’re guaranteed to stay in sync with the target object. So, what are the downsides to using this technique? There are two, one technical and one performance-related.

The caveats

The first problem is that our proxy cannot exactly match the behavior of Uint8Array, due to the semantics of ES6 Proxies. Specifically — calling Object.getOwnPropertyDescriptors on an instance of Uint8Array will indicate that the array index properties are not configurable — they cannot be deleted (e.g. delete arr[0] will not do anything and will return false if arr is an instance of TypedArray). However, Proxyenforces an invariant that getOwnPropertyDescriptor must not indicate that a descriptor is not configurable if the target (in this case, an instance of GrowableUint8Array) does not contain the property. Since a GrowableUint8Array does not contain properties named “0”, “1”, “2”, etc we must return configurable: true for those property names in our proxy, even though they are not actually configurable. One consequence of this is that we cannot actually delete these property names, so we lie in our deleteProperty method and return true for array indices without actually deleting them. Therefore, any code that relies on delete returning false for TypedArray indices will not work properly with this proxy.

The second, potentially more serious problem, is with performance. Most javascript engines have highly optimized implementations of TypedArray, whereas using a Proxy means that every property access will incur the overhead of multiple function calls. Let’s see how much of a difference it makes:

The actual numbers will depend on your environment and hardware. In my own browser, using the Runkit emulated environment, things don’t seem too bad — 846ms for the TypedArray loop, and 2,017ms for the proxy loop. Running the code directly in node tells a different story, however: 3ms for the TypedArray loop vs 534ms for the proxy loop.

That’s a pretty significant performance hit! Proxy is quite powerful and flexible, but it comes at a cost. If we need that power (for example, to interface with legacy code), it can be a lifesaver, but if we don’t it’s better to forego the Proxy.

--

--

Lager

Lager is a tool that makes it easy to collect and analyze debug and diagnostic data. www.lagerdata.com