3

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?
user3840170
  • 26,597
  • 4
  • 30
  • 62
Casey
  • 490
  • 7
  • 11
  • Please ask **one** question per question, not three. At a minimum, your third question above really should be separate from the rest of this. – T.J. Crowder Aug 19 '19 at 10:01
  • The context of question three depends on questions 1 and 2. When a Proxy properly wraps a target (according to the documentation), it is much easier to see the values in the debugger (since they are the target). It is specifically when the implementation is reversed (as described above) that it becomes extra hard to see the values in the debugger. – Casey Aug 19 '19 at 10:20
  • That's fine, you can refer to your previous question or provide that same context. But "Why is Proxy like this?" and "How do I change how Proxies are shown in devtools?" are very different questions. – T.J. Crowder Aug 19 '19 at 10:24
  • "*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*" - no, that's not an application for JS proxies. You really should just enable [http compression](https://en.wikipedia.org/wiki/HTTP_compression) on your server. – Bergi Aug 20 '19 at 05:10
  • @Bergi Yeah, the approach here is more about avoiding unnecessary duplication of data crossing the wire in the first place (as opposed to compressing it). Having said that, There was a time in the past when I was looking at compressing it, but that didn't really end well for me (https://meta.stackoverflow.com/questions/375215/how-to-stop-people-from-closing-as-off-topic-when-asking-how-to-do-something). – Casey Aug 20 '19 at 06:09

1 Answers1

3

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;
 }
T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • Thanks for the reference, I hadn't seen that before, and reading it in that context makes sense insofar as it looks like a basic list of rules that are enforced to make the language internally consistent. If the "Proxy" class is just a javascript class I can understand why it has to follow these rules; but if it is a fundamental feature of the language I would have expected it could be exempted (but that's probably why I am not a language developer). – Casey Aug 19 '19 at 10:14
  • @Casey - :-) Yeah, the invariants are required even of exotic objects like Proxies, Arrays, bound functions... I know there was a **lot** of careful work around this with proxies (and IIRC the reason the `enumerate` trap had to be removed related indirectly to invariants via the cost of implementing it). Apparently it's important. I wasn't happy with my two reasons above so I've [pinged Allen Wirfs-Brock on Twitter](https://twitter.com/tjcrowder/status/1163393810459746304) to get his view. (He's great that way.) – T.J. Crowder Aug 19 '19 at 10:22
  • @Casey - I just suddenly remembered that the restriction you're thinking of only applies if the target object is not extensible. I've updated the answer a bit. – T.J. Crowder Aug 19 '19 at 14:57
  • @Casey - And AWB got back to me, and indeed my understanding of what the invariants were for was wrong. :-) So I've fixed that now, too. – T.J. Crowder Aug 19 '19 at 15:09
  • 1
    thank you so much! This is perfect. I had never considered the `getPrototypeOf` trap (in hindsight now, I see that it is literally the first trap discussed on the MDN doc page for a Proxy handler). – Casey Aug 20 '19 at 06:02
  • Turns out this doesn't work as well as I thought, for instance `console.log(Object.assign({}, proxy))` (the point of which is to convert the proxy back to a plain object) won't work. I tried wrapping the target array in an object, but that creates a new set of issues. Seems proxy's are just too tightly coupled to the target to do what I was hoping. – Casey Aug 22 '19 at 00:57
  • @Casey - For that to work, you'd need to handle the `ownKeys` trap to supply the names to copy. There's probably a new question in there somewhere. The coupling between the proxy and an *extensible* target object is (almost?) entirely under your control. – T.J. Crowder Aug 22 '19 at 06:45
  • 1
    If you handle `ownKeys` and return an array of the field names it complains that you didn't include `length` since it is a non-configurable property of `target`. Next I tried wrapping the array in an Object and using that as the target, but quickly found myself going down a rabbit hole. My takeaway is that a proxy is tightly coupled to its target. The solution in my original question is the only one that I have been able to make work; unfortunately, it makes debugging almost impossible since the actual values are so hidden. – Casey Aug 22 '19 at 11:04
  • I should also note that have some extra stuff in the `get` and `set` traps which handles updating databinding, and persisting in the backend (which is my primary justification for using a proxy). This concept of lazily "re-flating" the values was just something that I thought a Proxy was advertised to be able to do (until you read the fine print). – Casey Aug 22 '19 at 11:06
  • @Casey - Ah, yeah, `ownKeys` has to include non-configurable properties. (I had to refer back to Chapter 14 of upcoming book to remind myself of the `ownKeys` restrictions. :-D) You could always just return a proxy of a blank object -- which may (I think) be your original approach. I've added an example of doing that just in case it helps (if it is your current approach, it may cover an edge case or two you don't already have covered). – T.J. Crowder Aug 22 '19 at 12:25
  • I updated the original question to show a simplified version of what I have been using and then ran it (thanks for teaching me that you can include runnable snippets!). Turns out that, when running the Stack Overflow snippet the `console.log(proxy)` statement actually shows the fields and correct values (but it doesn't when run in node or in an actual browser). – Casey Aug 23 '19 at 02:04