Pitfall: not all objects can be wrapped transparently by proxies
A proxy object can be seen as intercepting operations performed on its target object – the proxy wraps the target. The proxy’s handler object is like an observer or listener for the proxy. It specifies which operations should be intercepted by implementing corresponding methods (get
for reading a property, etc.). If the handler method for an operation is missing then that operation is not intercepted. It is simply forwarded to the target.
Therefore, if the handler is the empty object, the proxy should transparently wrap the target. Alas, that doesn’t always work, as this blog post explains.
Wrapping an object affects this
Before we dig deeper, let’s quickly review how wrapping a target affects this
:
const target = {
foo() {
return {
thisIsTarget: this === target,
thisIsProxy: this === proxy,
};
}
};
const handler = {};
const proxy = new Proxy(target, handler);
If you call target.foo()
directly, this
points to target
:
> target.foo()
{ thisIsTarget: true, thisIsProxy: false }
If you invoke that method via the proxy, this
points to proxy
:
> proxy.foo()
{ thisIsTarget: false, thisIsProxy: true }
That’s done so that the proxy continues to be in the loop if, e.g., the target invokes methods on this
.
Objects that can’t be wrapped transparently
Normally, proxies with an empty handler wrap targets transparently: you don’t notice that they are there and they don’t change the behavior of the targets.
If, however, a target associates information with this
via a mechanism that is not controlled by proxies, you have a problem: things fail, because different information is associated depending on whether the target is wrapped or not.
For example, the following class Person
stores private information in the WeakMap _name
(more information on this technique):
const _name = new WeakMap();
class Person {
constructor(name) {
_name.set(this, name);
}
get name() {
return _name.get(this);
}
}
Instances of Person
can’t be wrapped transparently:
> const jane = new Person('Jane');
> jane.name
'Jane'
> const proxy = new Proxy(jane, {});
> proxy.name
undefined
jane.name
is different from the wrapped proxy.name
. The following implementation does not have this problem:
class Person2 {
constructor(name) {
this._name = name;
}
get name() {
return this._name;
}
}
const jane = new Person2('Jane');
console.log(jane.name); // Jane
const proxy = new Proxy(jane, {});
console.log(proxy.name); // Jane
Wrapping instances of built-in constructors
Instances of most built-in constructors also have a mechanism that is not intercepted by proxies. They therefore can’t be wrapped transparently, either. I’ll demonstrate the problem for an instance of Date
:
const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);
proxy.getDate();
// TypeError: this is not a Date object.
The mechanism that is unaffected by proxies is called internal slots. These slots are property-like storage associated with instances. The specification handles these slots as if they were properties with names in square brackets. For example, the following method is internal and can be invoked on all objects O
:
O.[[GetPrototypeOf]]()
However, access to internal slots does not happen via normal “get” and “set” operations. If getDate()
is invoked via a proxy, it can’t find the internal slot it needs on this
and complains via a TypeError
.
For Date
methods, the language specification states:
Unless explicitly stated otherwise, the methods of the Number prototype object defined below are not generic and the
this
value passed to them must be either a Number value or an object that has a[[NumberData]]
internal slot that has been initialized to a Number value.
Arrays can be wrapped transparently
In contrast to other built-ins, Arrays can be wrapped transparently:
> const p = new Proxy(new Array(), {});
> p.push('a');
> p.length
1
> p.length = 0;
> p.length
0
The reason for Arrays being wrappable is that, even though property access is customized to make length
work, Array methods don’t rely on internal slots – they are generic.
A work-around
As a work-around, you can change how the handler forwards method calls and selectively set this
to the target and not the proxy:
const handler = {
get(target, propKey, receiver) {
if (propKey === 'getDate') {
return target.getDate.bind(target);
}
return Reflect.get(target, propKey, receiver);
},
};
const proxy = new Proxy(new Date('2020-12-24'), handler);
proxy.getDate(); // 24
The drawback of this approach is that none of the operations that the method performs on this
go through the proxy.
Further reading
- Comprehensive introduction to ES6 proxies: chapter “Metaprogramming with proxies” in “Exploring ES6”.
Acknowlegement: Thanks to Allen Wirfs-Brock for pointing out the pitfall explained in this blog post.
Comments
Post a Comment