What is the rationale behind the invariants of the [[Get]] internal method of Proxy exotic objects?

The specification has the following invariants:

  • The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable own data property.
  • The value reported for a property must be undefined if the corresponding target object property is a non-configurable own accessor property that has undefined as its [[Get]] attribute.

(1) I'd like to learn more on what purposes these invariants serve. Seemingly these are questionable limitations of certain uses of Proxies, which, after all, have been introduced in order to enable modification of the target Object's [[Get]] behavior.

(2) More specifically, it seems weird that the invariant only applies to a direct target object property and not to an inherited property:

const handler = {
  get(target,property) {
    return `proxy:${Reflect.get(target,property)}`;
  }
};

const foo = Object.create(null);
foo.a = "foo.a";
Object.freeze(foo);

const bar = Object.create(foo);
bar.b = "bar.b";
Object.freeze(bar);

const p = new Proxy(bar,handler);

console.log(p.a); // "proxy:foo.a" -- invariant does not apply here (why ?!)
console.log(p.b); // TypeError     -- invariant applies here

The purpose is to ensure that the Proxy upholds the invariants of the essential internal methods, which apply to all objects, not just Proxies.

One of those invariants (for [[Get]]) is that the observed value of non-configurable non-writable own data properties does not change. A Proxy with a [[Get]] trap could easily violate this invariant by returning different values at different times, and there's only two good ways to prevent it: either remember every value previously returned, which is a lot of work, or simply enforce that in cases such as this the behavior of the Proxy corresponds to the behavior of the target, which presumably itself is upholding the necessary invariants. Obviously the spec goes the second route.

(For this reason I personally prefer to refer to the target as a witness, in the mathematical sense: the invariants on the Proxy are enforced by in certain cases deferring its behavior to its target, so that the target serves as a proof that the Proxy is upholding the invariants.)

The invariant doesn't apply to inherited properties because none of the invariants for [[Get]] say anything about inherited properties.

2 Likes

Thank you for the reply, it explains a lot. The link to the object invariants is very useful, I was not aware of those. I quickly read the invariants of (generic) [[Get]] and they make sense. It is now also clear that the Proxy invariants are more or less consequences of the generic invariants.

The approach seems to be:

  • non-configurable + non-writable (from now on: "frozen") nature of a property is the owning object's business. An inheriting object of course may override its value or other attributes;

  • if a Proxy is created on a target, the "proxified" behavior only concerns the direct target, not its prototype chain, as also supported by the fact that [[GetPrototypeOf]] applied to the Proxy object by default returns the same value as [[GetPrototypeOf]] applied to the target (and this is another invariant for non-extensible targets). In other words, the Proxy object "substitutes" the target but not its prototype chain.

  • the generic invariants (those that apply to all objects) must somehow be translated to invariants of Proxy objects in such a way that the translated Proxy invariants enforce the generic invariants no matter what the handler does, which is difficult since the behavior of the handler is not controlled by the underlying implementation.

However, the Proxy object is a new object that is not "equal to" or "same as" the target object. Therefore, the way the [[Get]] invariant for frozen properties is translated to an invariant of the Proxy [[Get]] is imperfect (as you put it: "either remember every value previously returned, which is a lot of work, or simply enforce that in cases such as this the behavior of the Proxy corresponds to the behavior of the target [...] Obviously the spec goes the second route." I get it, this is a simplification for the sake of the possibility of actual implementation. Nevertheless, this is a stronger (more limiting) invariant than strictly necessary, so not a perfect translation of the generic invariant (a perfect translation would be your first option). However, such imperfection seems to be unavoidable. All in all, this settles (1).

With respect to (2), it also OK. But the situation is a bit more complicated here. If what has been said so far is true then the TypeError thrown for p.b makes sense, because "b" acts as an own property of p (the Proxy object) and the value returned by handler.get() violates the invariant above. So far so good. However,

(3): Reflect.getOwnPropertyDescriptor(p,"b") returns {value:"bar.b", ...}, which feels awkward. Shouldn't this also invoke handler.get() and throw?

In addition: essentially "a" also acts as an own property of p (because the handler overrides the value that comes from the prototype chain, so property "a", as observed by a client of the Proxy object, is definitely not a property of either foo or bar, so "it must be" a property of p). Still it is not reported as such (e.g. by Reflect.getOwnPropertyDescriptor(p,"a") etc.).

This, however, may be a problem with the handler implementation, as it does not report "effective" property "a". So maybe the handler is wrong, it should implement handler.ownKeys() and handler.getOwnPropertyDescriptor() and report property "a"? Let's try to do that (also removing Object.freeze, which was a mistake as it triggers another, irrelevant, invariant):

const handler = {
  get(target,property) {
    return `proxy:${Reflect.get(target,property)}`;
  },
  ownKeys(target) {return Reflect.ownKeys(target).concat(["a"]);},
  getOwnPropertyDescriptor(target,property) {
    if (property === "a") {
      return {writable:false, configurable:false, enumerable:false, value:this.get(target,property)};
    }
    return Reflect.getOwnPropertyDescriptor(target,property);
  }
};

const foo = Object.create(null);
Reflect.defineProperty(foo,"a",{value:"foo.a"});

const bar = Object.create(foo);
Reflect.defineProperty(bar,"b",{value:"bar.b"});

const p = new Proxy(bar,handler);


console.log(p.a);           // "proxy:foo.a" -- OK, by (2), overriding `"a"` is allowed
console.log(Reflect.getOwnPropertyDescriptor(p,"a"));   // TypeError -- oops! (4)

console.log(p.b);         // TypeError     -- OK, by (1), overriding `"b"` is not allowed
console.log(Reflect.getOwnPropertyDescriptor(p,"b"));   // {value:"bar.b", ...} -- surprise! (3)

(3): should not this throw also?
(4): another invariant kicked in, I do not quite get it.

To sum it up: if we have frozen data properties on the target object and its prototype chain, then

  • a direct property cannot be overridden in the proxy, yet the property descriptor describes it with the original value,
  • an inherited property can be overridden in the proxy, yet the property descriptor cannot be reported by the proxy.

Seems like overriding frozen data properties consistently with proxies is kind of impossible either way.

Why should it? It's not necessary to do so to uphold the invariants of the internal methods.

That's one way of thinking of it, and certainly you could design your Proxy in this way, but that's not the perspective the spec takes. The spec makes essentially no guarantees about the behavior of properties which are not frozen. You can make your Proxy handle those however you want. If there's some particular model you're trying to present (for example, if you're trying to ensure this behaves like an ordinary object) you can choose to do that, but the spec does not require you to.

Again, why should it? It is not necessary to uphold the invariants.

An invariant for [[HasProperty]] is that a property previously reported to be an own non-configurable property must always remain present from the perspective of future calls to [[HasProperty]]. The way this is enforced for Proxies is that the getOwnPropertyDescriptor handler can only report a property to be non-configurable if that property is also present and non-configurable on the target, and the has handler is required to report that a property is present whenever the property is present and non-configurable in the target.

That is, your getOwnPropertyDescriptor is trying to claim that there is a frozen property a on the proxy. But because that property is missing on the proxy target, your Proxy could start claiming otherwise in the future, which would make the property not appear to be frozen, which would be bad. So you are not allowed to claim that there's a frozen property unless the property is also present and frozen on the target, and if there is in fact a property which is present and frozen on the target then the Proxy must report it faithfully.

Correct. Frozen properties have lots of important invariants and naively overriding such properties with a Proxy would allow you to violate the invariants.

Again, I recommend not thinking of the Proxy as "overriding" the behavior of the target. The core purpose of the target is to enforce the invariants of the internal methods on the Proxy. The target also happens to provide fallback behavior for handlers which are not implemented, but this is mainly for convenience.

One bit of flexibility here is that the target (witness object) can be some new object that the proxy "owns" and mutates it as nessesary to fulfil the view that the proxy wants to project as its traps are called. Effectively building up the state of the witness lazily.

This can work well, though one Object.isFrozen check can force it to decide on anything not yet set in stone.

1 Like

Yes, and the proxy can behave exotically. It does not have to implement its properties as own properties, or does not have to oblige requests for making the proxy and its properties frozen. The proxy's target does not have to reflect exactly the "real" target's shape.

1 Like