Search code examples
javascriptes6-classes6-proxy

How to extend a class wrapped in a Proxy


I have a complex class that requires certain arguments to be passed to the constructor. However, I am exposing a simplified API to customers.

My internal class looks something like this:

class Foo {
  constructor(p, c) {
  }

  foo() {
  }
}

Where p is an internal reference that isn't conveniently accessible to customers.

Supporting a Public API

I want to allow customers to create instances of this class, but I don't want them to need a reference to the private p object. For consumers of this API, accessing p would be laborious and break existing code, so I want to hide it with an alias.

Sub-classes to the rescue? Almost.

At first I simply extended Foo, hid the private argument (by supplying the code to access it), and exposed it via the public API:

class PublicFoo extends Foo {
  constructor(c) {
    // Use internal functions to get "p"
    var p;
    super(p, c);
  }
}

This very nearly worked, but I ran into a major flaw. There are situations where the customer will need to test the type of an object. Depending on the situation, Foo might be created internally using the internal class or by the customer using the public API.

If the public API was used to create an instance of Foo, then internal instaceof checks work just fine: publicFoo instanceof Foo returns true. But, if the API created an instance of Foo using the internal class, then public instanceof checks fail: internalFoo instanceof PublicFoo returns false. The customer can type check instances created using the public API, but the same type checks fail for instances created internally (for example, by factory functions).

This is to be expected and makes sense to me, but it breaks my use-case. I can't use a simple sub-class because the sub-class is not a reliable alias for the internal class.

var f = new Foo();
f instanceof PublicFoo; // false

What about a Proxy?

So I bumped the "clever" gear up a notch and tried using a Proxy instead, which seemed(famous last words) to be a perfect solution:

var PublicFoo = new Proxy(Foo, {
  construct(target, args) {
    // Use internal functions to get "p"
    var p;
    return new target(p, ...args);
  }
});

I can expose the Proxy, intercept calls to the constructor, provide the necessary private object reference and instanceof isn't broken!

var f = new Foo();
f instanceof PublicFoo; // true!!!

But Proxy breaks inheritance...

Disaster! Customers can no longer inherit from (their version of) Foo!

class Bar extends PublicFoo {
  constructor(c) {
    super(c);
  }

  bar() {
  }
}

The Proxy's constructor trap always returns a new instance of Foo, not the subclass Bar.

This leads to terrible, terrible issues like:

(new Bar()) instanceof Bar; // false!!!! 😱😱😱

and

var b = new Bar();
b.bar() // Uncaught TypeError: b.bar is not a function

I'm Stuck.

Is there any way to meet all of the following criteria:

  • My public API must implicitly provide the "private" constructor arg (but, for reasonsâ„¢, I can't do that in the Foo constructor itself... it must be done via a wrapper or intercept of some sort)
  • The public API's version of Foo should be a thin alias of Foo. So, given f = new Foo(), then f instanceof PublicFoo should return true
  • PublicFoo should still support extends. So given class Bar extends PublicFoo, then (new Bar()) instanceof Bar should return true.

Here's an interactive demonstration of my Proxy conundrum:

class Foo {
  constructor(p, c) {
    this.p = p;
    this.c = c;
  }
  
  foo() {
    return "foo";
  }
}

var PublicFoo = new Proxy(Foo, {
  construct(target, args) {
    var p = "private";
    return new target(p, ...args);
  }
});

var foo = new Foo("private", "public");
console.assert( foo instanceof PublicFoo, "Foo instances are also instances of PublicFoo" );
console.assert( foo.p === "private" );
console.assert( foo.c === "public" );

var publicFoo = new PublicFoo("public");
console.assert( publicFoo instanceof Foo, "PublicFoo instances are also instances of Foo" );
console.assert( publicFoo.p === "private" );
console.assert( publicFoo.c === "public" );

class Bar extends PublicFoo {
  constructor(c) {
    super(c);
  }
  
  bar() {
    return "bar";
  }
}

var i = new Bar("public");
console.assert( i instanceof Bar, "new Bar() should return an instance of Bar" );
console.assert( i.p === "private" );
console.assert( i.c === "public" );
i.foo(); // "foo"
i.bar(); // Uncaught TypeError: i.bar is not a function


Solution

  • What about a Proxy? A proxy breaks inheritance as it always returns a new instance of Foo, not the subclass Bar.

    That's because your proxy implementation always constructed a new target, not accounting for the newTarget parameter and passing it on to Reflect.construct:

    const PublicFoo = new Proxy(Foo, {
      construct(Foo, args, newTarget) {
        const p = …; // Use internal functions to get "p"
        return Reflect.construct(Foo, [p, ...args], newTarget);
      }
    });
    

    See also What is "new.target"?.