Search code examples
javascriptnode.jsecmascript-6es6-proxy

Nested ES6 Proxies not working as expected


I'm working on an API client that allows to call specific API methods when providing the ID of a foo, like so:

apiClient.myApiMethod('myFooId', 'firstApiArg', 'nthApiArg');

For developer convenience, I'm trying to implement custom proxy objects:

var myFoo = apiClient.registerFoo('myFoo', 'myFooId');
myFoo.myApiMethod('firstApiArg', 'nthApiArg');

After searching for a while, I figured ES6 proxies may be best suited for that, as the fooId needs to be inserted as the first argument of the method call to support both ways of working.
Therefore, I created the following code. If an object property of Foo.myFoos is called (eg. Foo.myFoos.example), its is searched for in _myFooItems, and if it exists there, another Proxy object is returned.
Now if a method is called on that object, it is searched for in the properties of Foo, and if found, the Foo method is called with myFooId as its first argument.
That means, you should be able to Foo.myFoos.example.parentMethodX('bar', 'baz').

var Foo = function() {

  // parent instance
  _self = this;

  // custom elements dictionary
  _myFooItems = {};

  // to call parent methods directly on custom elements
  this.myFoos = Object.create(new Proxy({}, {

      // property getter function (proxy target and called property name as params)
      get: function(target, myFooName) {

        // whether called property is a registered foo
        if (_myFooItems.hasOwnProperty(myFooName)) {

          // create another proxy to intercept method calls on previous one
          return Object.create(new Proxy({}, {

              // property getter function (proxy target and called property name as params)
              get: function(target, methodName) {

                // whether parent method exists
                if (_self.hasOwnProperty(methodName)) {

                  return function(/* arguments */) {

                    // insert custom element ID into args array
                    var args = Array.prototype.slice.call(arguments);
                    args.unshift(_myFooItems[ myFooName ]);

                    // apply parent method with modified args array
                    return _self[ methodName ].apply(_self, args);
                  };
                } else {

                  // parent method does not exist
                  return function() {
                    throw new Error('The method ' + methodName + ' is not implemented.');
                  }
                }
              }
            }
          ));
        }
      }
    }
  ));


  // register a custom foo and its ID
  this.registerFoo = function(myFooName, id) {

    // whether the foo has already been registered
    if (_myFooItems.hasOwnProperty(myFooName)) {
      throw new Error('The Foo ' + myFooName + ' is already registered in this instance.');
    }

    // register the foo
    _myFooItems[ myFooName ] = id;

    // return the created foo for further use
    return this.myFoos[ myFooName ];
  };
};

module.exports = Foo;

Though what happens if you run the code and try to register a foo (the above code is working as is in Node>=6.2.0), is that the following Error is thrown:

> var exampleFoo = Foo.registerFoo('exampleFoo', 123456)
Error: The method inspect is not implemented.
  at null.<anonymous> (/path/to/module/nestedProxyTest.js:40:31)
  at formatValue (util.js:297:21)
  at Object.inspect (util.js:147:10)
  at REPLServer.self.writer (repl.js:366:19)
  at finish (repl.js:487:38)
  at REPLServer.defaultEval (repl.js:293:5)
  at bound (domain.js:280:14)
  at REPLServer.runBound [as eval] (domain.js:293:12)
  at REPLServer.<anonymous> (repl.js:441:10)
  at emitOne (events.js:101:20)

After spending way to much time thinking about why the second proxy even tries to call a method if none is given to it, I eventually gave up. I'd expect exampleFoo to be a Proxy object that accepts Foo methods if called.
What causes the actual behaviour here?


Solution

  • I don't think you should use proxies here at all. Assuming you have that API with a monstrous

    class Foo {
        …
        myApiMethod(id, …) { … }
        … // and so on
    }
    

    then the cleanest way to achieve what you are looking for is

    const cache = new WeakMap();
    Foo.prototype.register = function(id) {
        if (!cache.has(this))
            cache.set(this, new Map());
        const thisCache = cache.get(this);
        if (!thisCache.get(id))
            thisCache.set(id, new IdentifiedFoo(this, id));
        return thisCache.get(id);
    };
    
    class IdentifiedFoo {
        constructor(foo, id) {
            this.foo = foo;
            this.id = id;
        }
    }
    Object.getOwnPropertyNames(Foo.prototype).forEach(function(m) {
        if (typeof Foo.prototype[m] != "function" || m == "register") // etc
            return;
        IdentifiedFoo.prototype[m] = function(...args) {
            return this.foo[m](this.id, ...args);
        };
    });
    

    so that you can do

    var foo = new Foo();
    foo.myApiMethod(id, …);
    foo.register(id).myApiMethod(…);