Goal: in the internal [[Get]], [[Put]] and [[HasProperty]] methods for Objects, remove the dependency on [[GetProperty]] for walking the prototype chain.
Motivation: mainly because this dependency is made visible by proxies, and the interaction is obscure and not intuitive. This strawman proposes a refactoring of [[Get]], [[Put]] and [[HasProperty]] that enables much more sensible behavior for proxies used as prototypes.
The refactoring proposed by this strawman entails the following:
getPropertyDescriptor and getPropertyNames traps on proxiesget and set traps on proxies to work for inherited property access (i.e. when the proxy is used as a prototype).Reflect.get and Reflect.set built-ins to enable computed property get/set that is faithful to the spec algorithm. These built-ins are required for a proxy to faithfully “continue” an intercepted [[Get]] or [[Put]] on the prototype chain of the object it wraps.Note: the term “refactoring” is not used lightly here: we do intend that for regular objects and in the absence of proxies on the prototype chain, the below proposed algorithms are semantically equivalent to the ES5 algorithms.
When performing property access (obj[name]), assignment (obj[name]=val) or lookup (name in obj) on an object obj that inherits from a proxy, the get, set and has traps of that proxy were previously not triggered. Instead, the built-in algorithms would climb the prototype chain of obj by calling [[GetProperty]] (triggering the getPropertyDescriptor trap of the proxy-as-prototype). Based on the returned descriptor, each algorithm then further decided what to do. This behavior was counter-intuitive and in order to really understand what was going on as a Proxy author, you needed detailed knowledge of the spec algorithms.
The ES5 spec was written before the advent of proxies, and thus many of the design decisions used to construct the built-in algorithms were not observable to user code. Proxies make some choices observable, requiring us to revisit the particulars of the spec algorithms, especially for values of type Object.
This proposal refactors [[Get]], [[Put]] and [[HasProperty]] such that these algorithms themselves climb the prototype chain, rather than delegating this to [[GetProperty]]. This simplifies the Proxy API because it:
getPropertyDescriptor and getPropertyNames traps (2 less traps!)get, set and has traps both for own and inherited properties, which is more intuitive.
In addition, it simplifies reasoning about inheritance, since once a proxy’s get, set or has trap is triggered, the proxy now has full control over the outcome of the operation, unlike in the current API, where a) the handler has no clue on behalf of what operation its getPropertyDescriptor trap is triggered and b) the outcome of the operation only indirectly depends on the returned property descriptor.
Finally, the new semantics ought to be more efficient, since we avoid the need to allocate a property descriptor every time [[GetProperty]] is called on a proxy. Instead each of the get, set and has traps immediately return the appropriate value.
Proposed alternative for 8.12.3 [[Get]] (P)
When the [[Get]] internal method of O is called with property name P, the following steps are taken:
[[GetP]] (Receiver, P)
When the [[GetP]] internal method of O is called with initial receiver Receiver and property name P, the following steps are taken:
Note: [[GetP]] traverses the prototype chain until it either encounters a proxy or null. If it encounters a proxy, it is left to the proxy handler whether the operation continues on the prototype chain of its target.
On a proxy, [[Get]] is defined to be the same as for regular objects. [[GetP]] triggers the handler’s get trap, passing as arguments the property name, the receiver (that is: the object to which the initial property access was applied), the proxy target (for direct proxies) and the proxy itself:
var handler = { get: function(target, name, receiver) { // no-op forwarding. Note: can't do "return target[name]" // since that will set |this| to target instead of to receiver. // that's why we need this new primitive (discussed later): return Reflect.get(target, name, receiver); } }; var p = Proxy(target, handler); p[name] // triggers handler.get(target, name, p) var child = Object.create(p); child[name] // triggers handler.get(target, name, child)
The ES5 [[Put]] algorithm first calls the [[CanPut]] algorithm to determine whether the property can be assigned to. [[CanPut]] walks the proto chain once, by calling [[GetProperty]]. Then, if the property is indeed assignable, [[GetProperty]] is called _again_ to perform the actual assignment. This lead to an observable redundant trap invocation.
In the proposed alternative, [[Put]] calls an auxiliary [[SetP]] method that climbs the prototype chain, at each level determining whether the property is assignable. When [[SetP]] reaches the top of the chain, it can conclude that the property is assignable and performs the assignment on the initial receiver of [[Put]].
Note: in the absence of proxies, testing whether a property is assignable and setting the property could be done atomically. Proxies could make observable changes in their getPropertyDescriptor trap, such that this atomicity was lost. Andreas Rossberg commented about this lost invariant on es-discuss. The below refactoring avoids this hazard: once a proxy on the proto chain is reached, the built-in algorithm transfers full control to the proxy. It does not try to assign the property later, based on possibly false answers returned by the proxy’s getPropertyDescriptor trap.
Proposed alternative for 8.12.5 [[Put]] (P, V, Throw)
When the [[Put]] internal method of O is called with property P, value V, and Boolean flag Throw, the following steps are taken:
[[SetP]] (Receiver, P, V)
When the [[SetP]] internal method of O is called with initial receiver Receiver, property name P and value V, the following steps are taken:
Note: [[SetP]] traverses the prototype chain until it either encounters a proxy or null. If it encounters a proxy, it is left to the proxy handler whether the operation continues on the prototype chain of its target.
On a proxy, [[Put]] is defined to be the same as for regular objects. [[SetP]] triggers the handler’s set trap, passing as arguments the direct proxy’s target, the assigned property name and value and the receiver (that is: the object to which the initial property assignment was applied):
var handler = { set: function(target, name, value, receiver) { // no-op forwarding. Note: can't do "target[name] = value;" // since that will set |this| to target instead of to receiver // if target[name] is an accessor. // That's why we need this new primitive (discussed later): return Reflect.set(target, name, value, receiver); } }; var p = Proxy(target, handler); p[name] = val // triggers handler.set(target, name, val, p) var child = Object.create(p); child[name] = val // triggers handler.set(target, name, val, child)
Proposed alternative for 8.12.6 [[HasProperty]] (P)
var handler = { has: function(target, name) { // no-op forwarding: // no new built-in required to faithfully forward "in" return name in target; } }; var p = Proxy(target, handler); name in p // triggers handler.has(target, name) var child = Object.create(p); name in child // triggers handler.has(target, name)
(Note: these built-ins were previously referred to as Object.getProperty and Object.setProperty)
As noted in the code snippets above, in order to faithfully forward an intercepted [[Get]] or [[Put]] operation, a proxy needs new built-ins that allow it to trigger the [[GetP]] and [[SetP]] operations of its target, passing along the original receiver. This is crucial in order to faithfully emulate inherited accessor properties.
The following built-ins are added to the reflect api:
Reflect.get = function(target, name, receiver?) -> any Reflect.set = function(target, name, value, receiver?) -> boolean
The trailing receiver parameter is optional and defaults to target. If specified, when an accessor is encountered on the prototype chain, the accessor’s this-binding will be set to the given receiver parameter. Note the following equivalences in the absence of accessors:
Reflect.get(obj, name) ~ obj[name] Reflect.set(obj, name, val) ~ obj[name] = val // except for return values
The built-ins are specified as follows:
Reflect.get ( O, P, Receiver)
Reflect.set ( O, P, V, Receiver)
The above built-ins can be expressed in Javascript itself, with the limitation that if the target is a proxy, it will fail to trigger the proxy’s get or set trap. Hence, the following implementation is non-normative and for illustrative purposes only:
Reflect.get = function(target, name, receiver) { receiver = receiver || target; var desc = Object.getOwnPropertyDescriptor(target, name); if (desc === undefined) { var proto = Object.getPrototypeOf(target); if (proto === null) { return undefined; } return Reflect.get(proto, name, receiver); } if (isDataDescriptor(desc)) { return desc.value; } var getter = desc.get; if (getter === undefined) { return undefined; } return desc.get.call(receiver); }; Reflect.set = function(target, name, value, receiver) { receiver = receiver || target; var ownDesc = Object.getOwnPropertyDescriptor(target, name); if (ownDesc !== undefined) { if (isAccessorDescriptor(ownDesc)) { var setter = ownDesc.set; if (setter === undefined) return false; setter.call(receiver, value); // assumes Function.prototype.call return true; } // otherwise, isDataDescriptor(ownDesc) must be true if (ownDesc.writable === false) return false; if (receiver === target) { var updateDesc = {value: value}; Object.defineProperty(receiver, name, updateDesc); return true; } else { if (!Object.isExtensible(receiver)) return false; var newDesc = { value: value, writable: true, enumerable: true, configurable: true }; Object.defineProperty(receiver, name, newDesc); return true; } } var proto = Object.getPrototypeOf(target); if (proto === null) { if (!Object.isExtensible(receiver)) return false; var newDesc = { value: value, writable: true, enumerable: true, configurable: true }; Object.defineProperty(receiver, name, newDesc); return true; } return Reflect.set(proto, name, value, receiver); };
The following source code implements Object.setProperty both using the current ES5 semantics and using the proposed alternative semantics. It then sets up various tests to check whether their behavior is equivalent on normal objects (not considering proxies), and also whether these implementations are equivalent to built-in property assignment triggered by obj[name]=value;. You can run this script in your browser here.