Search code examples
javascriptarrayses6-proxy

Why is [[GetPrototypeOf]] an Invariant of a Javascript Proxy?


One application for a Javascript Proxy object is to reduce network traffic by sending data over the wire as an array of arrays, along with an object listing the field names and index of each field (ie. a field map). (instead of an array of objects where the property names are repeated in each object).

At first glance, it would seem that an ES6 Proxy would be a great way to consume the data on the client side (ie. with the array as the target, and a handler based on the field map).

Unfortunately, Javascript Proxy's have a concept of "invariants", and one of them is that:

[[GetPrototypeOf]], applied to the proxy object must return the same value as [[GetPrototypeOf]] applied to the proxy object’s target object.

In other words, it is not possible to make an array appear as an object (because the prototype of array isn't the same as the prototype of an Object).

A workaround is to make the Object containing the field/index mapping the target, and embed the values in the Proxy handler. This works, but feels dirty. It is basically exactly the opposite of what the Proxy documentation presents and instead of using one "handler" with lots of "targets" it is essentially using lots of "handlers" (each in a closure around the array of values the proxy is representing) all sharing the same "target" (which is the field/index map).

'use strict';

class Inflator {
  constructor(fields, values) {
    // typically there are additional things in the `set` trap for databinding, persisting, etc.
    const handler = {
      get: (fields, prop) => values[(fields[prop] || {}).index],
      set: (fields, prop, value) => value === (values[fields[prop].index] = value),
    };
    return new Proxy(fields, handler);
  }
}

// this is what the server sends
const rawData = {
  fields: {
    col1: {index: 0}, // value is an object because there is typically additional metadata about the field
    col2: {index: 1},
    col3: {index: 2},
  },
  rows: [
    ['r1c1', 'r1c2', 'r1c3'],
    ['r2c1', 'r2c2', 'r2c3'],
  ],
};

// should be pretty cheap (memory and time) to loop through and wrap each value in a proxy
const data = rawData.rows.map( (row) => new Inflator(rawData.fields, row) );

// confirm we get what we want
console.assert(data[0].col1 === 'r1c1');
console.assert(data[1].col3 === 'r2c3');
console.log(data[0]); // this output is useless (except in Stack Overflow code snippet console, where it seems to work)
console.log(Object.assign({}, data[0])); // this output is useful, but annoying to have to jump through this hoop
for (const prop in data[0]) { // confirm looping through fields works properly
  console.log(prop);
}

So:

  1. Since it is obviously possible to make an array appear to be an object (by holding the array of values in the handler instead of the target); why is this "invariant" restriction applicable in the first place? The whole point of Proxys are to make something look like something else.

and

  1. Is there a better/more idiomatic way to make an array appear as an object than what is described above?

Solution

  • You've left off an important part of that note in the spec:

    If the target object is not extensible, [[GetPrototypeOf]] applied to the proxy object must return the same value as [[GetPrototypeOf]] applied to the proxy object's target object.

    (my emphasis)

    If your array of arrays is extensible (the normal case), you can return any object you want (or null) from the getPrototypeOf trap:

    const data = [0, 1, 2];
    const proxy = new Proxy(data, {
        getPrototypeOf(target) {
            return Object.prototype;
        },
        get(target, propName, receiver) {
            switch (propName) {
                case "zero":
                    return target[0];
                case "one":
                    return target[1];
                case "two":
                    return target[2];
                default:
                    return undefined;
            }
        }
    });
    console.log(Object.getPrototypeOf(proxy) === Object.prototype); // true
    console.log(proxy.two); // 2

    Re the invariants, though, it's not just proxies; all objects (both ordinary and exotic) in JavaScript are required to adhere to certain invariants laid out in the Invariants of the Essential Internal Methods section. I pinged Allen Wirfs-Brock (former editor of the specification, and editor when the invariants language was added) about it on Twitter. It turns out the invariants are primarily there to ensure that sandboxes can be implemented. Mark Miller championed the invariants with Caja and SES in mind. Without the invariants, apparently sandboxes couldn't rely on integrity-related constraints such as what it means for objects to be "frozen" or for a property to be non-configurable.

    So getting back to your proxy, you might just leave your array of arrays extensible (I take it you're freezing it or something?), since if you don't expose it, you don't need to defend against other code modifying it. But barring that, the solution you describe, having an underlying object and then just having the handlers access the array of arrays directly, seems a reasonable approach if you're going to use a Proxy for this purpose. (I've never felt the need to. I have needed to reduce network use in almost exactly the way you describe, but I just reconstituted the object on receipt.)

    I don't believe there's any way to modify what devtools shows for a proxy, other than writing a devtools mod/extension. (Node.js used to support an inspect method on objects that modified what it showed in the console when you output the object, but as you can imagine, that caused trouble when the object's inspect wasn't meant for that purpose. Maybe they'll recreate it with a Symbol-named property. But that would be Node.js-specific anyway.)


    You've said you wan to be able to use Object.assign({}, yourProxy) if necessary to convert your proxy into an object with the same shape, and that you're having trouble because of limitations on ownKeys. As you point out, ownKeys does have limitations even on extensible objects: It can't lie about non-configurable properties of the target object.

    If you want to do that, you're probably better off just using a blank object as your target and adding fake "own" properties to it based on your arrays. That might be what you mean by your current approach. Just it case it isn't, or in case there are some edge cases you may not have run into (yet), here's an example I think covers at least most of the bases:

    const names = ["foo", "bar"];
    const data = [1, 2];
    const fakeTarget = {};
    const proxy = new Proxy(fakeTarget, {
        // Actually set the value for a property
        set(target, propName, value, receiver) {
            if (typeof propName === "string") {
                const index = names.indexOf(propName);
                if (index !== -1) {
                    data[index] = value;
                    return true;
                }
            }
            return false;
        },
        // Actually get the value for a property
        get(target, propName, receiver) {
            if (typeof propName === "string") {
                const index = names.indexOf(propName);
                if (index !== -1) {
                    return data[index];
                }
            }
            // Possibly inherited property
            return Reflect.get(fakeTarget, propName);
        },
        // Make sure we respond correctly to the `in` operator and default `hasOwnProperty` method
        // Note that `has` is used for inherited properties, not just own
        has(target, propName) {
            if (typeof propName === "string" && names.includes(propName)) {
                // One of our "own" properties
                return true;
            }
            // An inherited property, perhaps?
            return Reflect.has(fakeTarget, propName);
        },
        // Get the descriptor for a property (important for `for-in` loops and such)
        getOwnPropertyDescriptor(target, propName) {
            if (typeof propName === "string") {
                const index = names.indexOf(propName);
                if (index !== -1) {
                    return {
                        writable: true,
                        configurable: true,
                        enumerable: true,
                        value: data[index]
                    };
                }
            }
            // Only `own` properties, so don't worry about inherited ones here
            return undefined;
        },
        // Some operations use `defineProperty` rather than `set` to set a value
        defineProperty(target, propName, descriptor) {
            if (typeof propName === "string") {
                const index = names.indexOf(propName);
                if (index !== -1) {
                    // You can adjust these as you like, this disallows all changes
                    // other than value
                    if (!descriptor.writable ||
                        !descriptor.configurable ||
                        !descriptor.enumerable) {
                        return false;
                    }
                }
                data[index] = descriptor.value;
                return true;
            }
            return false;
        },
        // Get the keys for the object
        ownKeys() {
            return names.slice();
        }
    });
    console.log(proxy.foo);                              // 1
    console.log("foo" in proxy);                         // true
    console.log("xyz" in proxy);                         // false
    console.log(proxy.hasOwnProperty("hasOwnProperty")); // false
    const obj = Object.assign({}, proxy);
    console.log(obj);                                    // {foo: 1, bar: 2}
    proxy.foo = 42;
    const obj2 = Object.assign({}, proxy);
    console.log(obj2);                                   // {foo: 42, bar: 2}
    .as-console-wrapper {
        max-height: 100% !important;
     }