Ademola Adegbuyi
Ademola Adegbuyi

Reputation: 1054

Major use cases for ES6 proxies

I recently got to know about ES6 proxies but I don't see a good reason to use it. I mean, everything that one could do with Proxy can be done without it, except if I'm missing something.

For example, most folks talk about validation when it comes to proxy but one could apply some JS goodness to validate and everyone is fine. I would appreciate if someone could open my eyes to some major use cases of Proxies. Thanks!

Upvotes: 22

Views: 5734

Answers (5)

dumbass
dumbass

Reputation: 27228

The use cases are basically twofold: custom dispatch and object-graph isolation. There is also one notable non-use-case that happens to be an anti-pattern, which newcomers to Proxy (and, unfortunately, some veterans too) are prone to re-discovering and falling into. Proxy is a tricky primitive to use well if you don’t know the intricacies of the ECMAScript object model. If you can think of two solutions to a problem, where one of them uses Proxy and you can’t think of a reason to use it better than “neater syntax”, use the other solution. That said, there are a few recipes based on Proxy that will probably not end in disaster when attentively followed.

Let’s go over the use cases and the non-use-case.

Custom dispatch

Proxy allows user code to create exotic objects – ones that run custom code when certain operations (like property accesses) are performed on them, that would otherwise be realised entirely by language intrinsics.

A “proxy” has a “target”, which may suggest that the “proxy” is supposed to intercept accesses to what is otherwise a fully-functional object, but in this use case those names turn out to be rather misleading. The “proxy” name should instead evoke dynamic proxies known from Java (java.lang.reflect.Proxy), while the “target” is best thought of like an archetype that the Proxy approximates (similarly to how an object’s prototype is an archetype), and which provides fallback behavior for unimplemented traps. For what it’s worth, the original design of Proxy did not feature any target object at all: unimplemented traps would either delegate to other, more basic traps, or throw an error.

For a relatively silly example, here’s how one can use Proxy to exfiltrate the abstract operation named ToPropertyKey in the specification:

const toPropertyKey = (() => {
  const keyCaster = new Proxy(
    Object.create(null), { get(_, prop) { return prop; } });
  return (value) => keyCaster[value];
})();

const sym = Symbol();

console.log(toPropertyKey(1) === "1");
console.log(toPropertyKey(void 0) === "undefined");
console.log(toPropertyKey({}) === "[object Object]");
console.log(toPropertyKey(sym) === sym);
console.log(toPropertyKey(Symbol.toStringTag) === Symbol.toStringTag);

You may think: cute, but why would anyone use something like this over a one-liner reimplementation like typeof prop === 'symbol' ? prop : `${prop}`? One possible reason is that you may want to account for the possibility that in the future ECMAScript will introduce new types of property keys, for example to support Python-like slicing syntax. The reimplementation would then become wrong, while the Proxy technique will track the definition of ToPropertyKey exactly.

User-code dispatch also allows to create polyfills of native exotic objects. Here’s for example a sketch of a class replicating the functionality of the .dataset DOM property:

class MyDataSet extends null {
  static {
    Object.setPrototypeOf(
      this, class { constructor(self) { return self; } });
  }

  #elem;

  static #targetToProxy = new WeakMap;

  static #traps = {
    get(target, prop, thisArg) {
      if (typeof prop !== 'string')
        return Reflect.get(...arguments);
      const proxy = MyDataSet.#targetToProxy.get(target);
      return proxy.#elem.getAttribute(`data-${prop}`);
    },

    set(target, prop, value, thisArg) {
      if (typeof prop !== 'string')
        return Reflect.set(...arguments);
      const proxy = MyDataSet.#targetToProxy.get(target);
      proxy.#elem.setAttribute(`data-${prop}`, value);
      return true;
    },

    /* other traps omitted for brevity;
     * ownKeys, defineProperty and deleteProperty
     * probably ought to be implemented as well
     */
  };

  constructor(elem) {
    const target = Object.create(new.target.prototype);
    const proxy = new Proxy(target, MyDataSet.#traps);
    MyDataSet.#targetToProxy.set(target, proxy);
    super(proxy); // this = proxy; // (and install fields)

    this.#elem = elem;
  }

  *[Symbol.iterator]() {
    for (const attr of this.#elem.attributes) {
      const m = /^data-(.*)$/.exec(attr.name);
      if (m) yield [m[1], attr.value];
    }
  }
}

Here, the class replaces its prototype with a constructor containing a return override. This both avoids the extends null constructor pitfall and allows private properties to be installed on the proxy returned by the quasi-superclass. The initially-constructed object used as the target is also added to a WeakMap back to the proxy, so that the proxy traps (which would otherwise only be able to access the target) can access the installed private properties too. A now-inactive new.initialize proposal would have made the above a bit more convenient to write, obviating the need for the static block. (The solution presented here is presumably what that proposal refers to as the “super return trick”.)

Object-graph isolation

More true to their name, Proxies can also be used to encapsulate a graph of objects and intercept all attempts to interact with any object in the graph. This is done through a technique known as a membrane; this term, as far as I can see, originated in the PhD thesis of Mark Miller; some other common terminology is explained in Alexander J. Vincent’s implementation of one. Implementation of membranes was one of the major motivations to introduce proxies.

In short, an object graph is simply a set of objects that can hold references to one another. A membrane then consists of:

  • a WeakMap I am going to call the nexus map that establishes a correspondence between objects in one graph and proxies in the other (and vice versa)
  • a few sets of traps that transparently convert between objects and proxies, so that one object graph always acts on real objects, and the other on their proxies (and vice versa)

The traps use the nexus map to ensure that the same proxy object is consistently used for the same target object, and that the proxy is unwrapped into the actual object once it returns into its graph of origin. The membrane traps may optionally decide to deny or alter operations performed on the proxies before they are forwarded to their targets; this is referred to as distortion.

A properly implemented membrane will make objects appear as if they come from a different environment, with its own set of built-in primitives – what the ECMAScript standard terms a realm. For (ahem) instance, membrane-wrapped arrays will not be instanceof Array, but will be instanceof the proxy wrapper the membrane created for Array on the other side of the membrane.

Proxy.revocable was especially created with this use case in mind; the original motivation for Proxy.revocable involved taking away browser extensions’ access to objects created by tabs that have been closed by the user.

Even a simple membrane is rather tricky to implement correctly. Below is a sketch of a revoking membrane that should demonstrate what it takes to implement one in full.

class MyRevocableMembrane {
  static #origin = Symbol('MyRevocableMembrane.#origin');

  #nexus = new WeakMap();

  static #blockUnknownTraps = {
    get(target, prop) {
      const trap = target[prop];
      if (!trap)
        throw new TypeError(`unimplemented trap ${prop}`);
      return trap;
    }
  };

  #traps = Object.create(null);

  #trapsFor(graph) {
    if (this.#traps[graph])
      return this.#traps[graph];

    const { apply: Reflect_apply, /* […] */ } = Reflect;

    return this.#traps[graph] = new Proxy({
      apply: (target, thisArg, args) => {
        const originGraph = this.#nexus.get(object)[MyRevocableMembrane.#origin];
        thisArg = this.#rewrap(thisArg, graph, originGraph);
        args = args.map(arg => this.#rewrap(arg, graph, originGraph));
        let ret;
        try {
          ret = Reflect_apply(target, thisArg, args);
        } catch (err) {
          throw this.#rewrap(err, originGraph, graph);
        }
        return this.#rewrap(ret, originGraph, graph);
      },

      /* other traps omitted for brevity; rest assured, you will
       * (probably) need to implement every single one of them
       * by appropriately re-wrapping arguments to Reflect[…] functions
       * and/or their results
       */
    }, MyRevocableMembrane.#blockUnknownTraps);
  }

  #revokers = [];

  #rewrap(object, graphFrom, graphTo) {
    if (Object(object) !== object)
      return object;
    let info = this.#nexus.get(object);
    if (!info) {
      this.#nexus.set(object, info = Object.create(null);
      info[info[MyRevocableMembrane.#origin] = graphFrom] = object;
    }
    let proxy = info[graphTo];
    if (!proxy) {
      let revoke;
      { proxy, revoke } = Proxy.revocable(
        info[info[MyRevocableMembrane.#origin]],
        this.#trapsFor(graphTo));
      this.#revokers.push(revoke);
      info[graphTo] = proxy;
      this.#nexus.set(proxy, info);
    }
    return proxy;
  }

  revoke() {
    if (!this.#revokers)
      return;
    const revokers = this.#revokers;
    this.#revokers = null;
    for (const revoker of revokers);
      revoker();
  }

  static create(seed) {
    const membrane = new this();
    return {
      membrane,
      seed: membrane.#rewrap(seed, 'dry', 'wet'),
    };
  }
}

Non-use-case: observing mutations of arbitrary objects

The target object normally provides a fallback for the proxy’s behaviour; if a trap function is absent from the handler object, the proxy defaults to acting on the target. Also, unless the proxy has been programmed to do otherwise, invoking a method on a proxy will have the method invoked with the proxy as the this value, which in turn means it will trigger proxy traps when the proxy is interacted with inside the method.

This seems to encourage users to use Proxy to detect when the object is being mutated in order to trigger update actions elsewhere and achieve reactivity; in essence, using Proxy as a replacement for the long-ago deprecated and removed Object.observe0. Like with many anti-patterns, the pernicious thing about this scheme is that it seemingly works well in toy examples1:

class MyCounter {
  constructor(initial = 0) { this.counter = initial; }
  increment() { return this.counter++; }
}

const ctr = new MyCounter();
const proxy = new Proxy(ctr, {
  set(target, prop) {
    console.log('mutated', prop);
    return Reflect.set(...arguments);
  }
});

proxy.increment();    //> mutated counter
proxy.increment();    //> mutated counter
proxy.increment();    //> mutated counter

But it can break in all sorts of subtle ways that ultimately boil down to the fact that the proxy is not actually the same object as its target. Although the above may seem superficially similar to a membrane distortion, an important distinction is that membranes can only observe and act on interactions between entire object graphs, not between individual objects within a graph. Membranes are especially not designed to observe an object acting upon itself. This is because you can’t force someone who already holds a direct reference to a proxy’s target to access it through the proxy.

In particular, using this scheme you may run into issues like the following:

  • By the time you construct the proxy, a reference to the object may have already been handed to somewhere where it will be observed. This may happen as early as when the object is being constructed.
  • WeakMaps and WeakSets compare keys based on object identity, and the proxy has a different identity from its target. If an object is added to a WeakMap, it will not be possible to look up its associated value using the proxy, and vice versa.
  • Private fields are likewise installed on the target, not on the proxy. Looking up private fields on proxies will fail with a TypeError, unless they have actually been installed on the proxy (like in the custom dispatch example before).

There may be others that I have not thought of, but those are the most prominent ones. Here’s a particularly pathological, yet not too unrealistic counterexample:

class MyTimer {
  #timerId = null;
  ticks = 0;

  constructor() {
    this.#timerId = setInterval(() => {
      this.ticks++;
    }, 1000);
  }
  
  reset() {
    this.ticks = 0;
  }

  stop() {
    if (this.#timerId === null)
      return;
    clearInterval(this.#timerId);
    this.#timerId = null;
  }
  
  get running() {
    return this.#timerId !== null;
  }
}

const t = new MyTimer();
const p = new Proxy(t, {
  set(target, prop, value, thisArg) {
    console.log('mutated:', prop, value);
    return Reflect.set(...arguments);
  }
});

setTimeout(() => {
  p.reset();                 //  the only time the 'set' trap is triggered
}, 2500);

setTimeout(() => {
  p.stop();                  //! TypeError
}, 5500);

console.log(p.running);      //! TypeError

(It could have been worse: .ticks could have been made an accessor property backed by a private field, meaning that it would not be accessible through the proxy at all, and no mutations to the value it returns would have been detectable by the proxy.)

Some, when they discover the pitfalls of misusing Proxy in this way, write long screeds about private fields being harmful; there is also a whole issue ticket with complaints about this. Others shrug and reply with the doctor-joke response: “well, don’t do that then”. As consolation, they are in good, or at least well-known, company: Vue 3 embraces this anti-pattern in its design. Then though, it was also once common in JavaScript to mutate prototypes of built-ins to add custom methods; these days we know better.


0 To be fair: MDN used to suggest this, so it’s not entirely their fault. But it was never a supported use case; in fact, the original design document for proxies explicitly listed a goal of:

security: avoid enabling arbitrary ES objects to be able to intercept the properties of another object

1 In fact, you may never run into any problems when you limit yourself to “POJO” objects – inert, record-like objects with no methods you might as well have obtained directly from JSON.parse. This is because, since the objects cannot act on themselves, all mutations to them have to come “from the outside” and therefore they can be considered a stand-alone object graph. However, limiting yourself to such types can be considered an anti-pattern in itself.

Upvotes: 2

T.J. Crowder
T.J. Crowder

Reputation: 1075289

I mean, every thing that one could do with Proxy can be done without it...

If that were true, TC39 wouldn't have added Proxy. But in fact there are things you can't do without Proxy.

Consider the common need to catch access to properties that don't exist:

const o = { foo: "bar" };
console.log(o.blarg);

It's common to want to handle that in a way other than the default, which is for accessing o.blarg to result in undefined:

const o = { foo: "bar" };
console.log(`foo:   ${o.foo}`);
console.log(`blarg: ${o.blarg}`);

Proxy lets us do that, via the get trap. For example, you could throw an error:

const o = { foo: "bar" };
const p = new Proxy(o, {
    get(target, prop, receiver) {
        if (prop in target) {
            return target[prop];
        }
        throw new Error(`Property "${prop}" doesn't exist in object`);
    }
});
console.log(`foo:   ${p.foo}`);
console.log(`blarg: ${p.blarg}`);

Another example is the ability to hook into the various operations that get the list of properties on an object. There is no way to hook into that without Proxy. With Proxy, it's easy: You use the has trap or the ownKeys trap depending on what you want to hook into.

In terms of other use cases: Proxy is the ultimate tool for implementing the Facade pattern. Look for the use cases of Facade, and you'll find use cases for Proxy.

Upvotes: 31

Daniel Agbanyim
Daniel Agbanyim

Reputation: 9

In ES6 Proxy offers the flexibility of eating your cake and having it back. You do not need to know beforehand the properties going to be get/set like in ES5. Now with ES6 Proxy you can add new property to an object like so: proxyObj.newProp = 9, Proxy will smile and set the new property without prejudice.

Upvotes: -1

Ja Superior
Ja Superior

Reputation: 469

There is actually lots you can do with it. Theres an awesome github repo where this guy put together a bunch of proxy resources which you can check out.

https://github.com/mikaelbr/proxy-fun

Also, check out my gists, I recently started playing around with proxies, and I have a couple examples that are pretty unique. You can essentially build your own DSL using proxy and program in a closer fashion to the way you think.

https://gist.github.com/jasuperior

Upvotes: 4

devios1
devios1

Reputation: 38025

Proxies represent a class of dynamic programming (as in dynamic languages, not the method of problem solving) called metaprogramming, and it is absolutely not the case that anything that can be done with proxies can be done without them. In fact that's really the reason proxies exist: to enable entirely new capabilities that weren't possible before.

Proxies enable you to intercept operations on your objects that would have otherwise been purely the responsibility of the JavaScript engine; property accessing and mutating being the two obvious examples.

T.J.'s answer is a good example of something you can't do without proxies. To give you another, I am using proxies to enable singleton instances of objective entities to allow their backing data stores to be swapped-out and replaced with entirely new objects, without affecting the references that are pointing to those objects.

To do this without proxies, we would have to iterate over each field of the object and swap them out for the new fields in the new object. While it's true JavaScript is dynamic enough to allow that to be possible, Proxies allow it to be solved in a much more elegant way: the hidden backing store of the proxy is simply replaced with the new object and all future property accesses are simply directed to the new backing store rather than the old one, while external references to the object (which is actually the proxy) need be none the wiser. To them, it appears as though it is the same object (because it is), but now it happens to have completely different data behind it.

This is only one example of what you can use Proxies for. They are really quite powerful because of how dynamic they are. I'm just getting to know them, but already I can say I'm quite in love. :)

Upvotes: 3

Related Questions