Pitfall: not all objects can be wrapped transparently by proxies

[2016-11-12] dev, javascript, esnext
(Ad, please don’t block)

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  

Acknowlegement: Thanks to Allen Wirfs-Brock for pointing out the pitfall explained in this blog post.