javascriptgoogle-chrometostringsymbolsproxy-pattern

`Proxy` confusing `this[toString]` with `this[Symbol.toStringTag]`


It only happens with #toString, and only when I (try to) access it through a missingMethod-like trap.

I have a factory called createIterface which returns a Proxy of an object with a large number of methods. Among this methods, I have both #toString() and #id(). #id returns an interface with the same attributes as the caller and works just fine; #toString should convert my interface to a String, but it fails. All interface's methods - including #id and #toString - are inside a #Symbol.for("__methods") attribute. I have made it this way for debugging's purpouses:

const __methods = Symbol.for("__methods");

const missingMethod = ({
    get: (obj, prop) => Reflect.has(obj, prop)
        ? Reflect.get(obj, prop)
        : Reflect.has(obj[__methods], prop)
            ? Reflect.get(obj[__methods], prop)
            : console.log(`No #${prop} property exists.`)
});

const createInterface = (...props) => new Proxy({
    ...props,
    [__methods]: {
        id: () => createInterface (...props),
        toString: () => `Interface(${ props.toString() })`
    }
}, missingMethod);

const interface = createInterface(0, 1, 2);
interface.id(); //works
interface.toString(); //error: Cannot convert a Symbol value to a string

The error throwed says it cannot (implicitly) convert Symbol to String (which is true). Thing is, #toString is not a Symbol. There is, however, a well-known Symbol called #toStringTag that defines Object#toString() behavior. When I implement it with the other methods my #toString() is ignored and interface returns '[object Object]':

// see code above
const createInterface = (...props) => new Proxy({
    ...props,
    [__methods]: {
        id: () => createInterface (...props),
        toString: () => `Interface(${ props.toString() })`,
        [Symbol.toStringTag]: () => "Interface"
    }
}, missingMethod);

const interface = createInterface(0, 1, 2);
interface.id(); //works
interface.toString(); //bug: '[object Object]'

If I code the methods outside __methods it all works fine:

// see code above
const createInterface = (...props) => new Proxy({
    ...props,
    id: () => createInterface (...props),
    toString: () => `Interface(${ props.toString() })`
}, missingMethod);

const interface = createInterface(0, 1, 2);
const copycat = interface.id();
interface.toString() === copycat.toString(); //true

Other than some weird browse bug (I'm running latest Chrome, which in day of this writing is v. 71.0.3578.98) I have no idea why this is happening or how to fix it.

Could someone help?


Solution

  • The problem is that accessing interface.toString first goes through

    get: (obj, prop) => Reflect.has(obj, prop)
        ? Reflect.get(obj, prop)
        : Reflect.has(obj[__methods], prop)
            ...
    

    You're expecting interface.toString to fall through the ternary here and get to the _methods, but Reflect.has(obj, 'toString') will evaluate to true because of Object.prototype.toString. Then, invoking that function on the object goes through the proxy's getter operation again, searching for a #toStringTag to call. The getter goes through all its ternaries and finds nothing, so it throws on the line

    console.log(`No #${prop} property exists.`)
    

    because prop is a symbol and cannot be concatenated.

    One possibility would be to use an object that does not inherit from Object.prototype:

    const obj = Object.create(null);
    const createInterface = (...props) => new Proxy(
      Object.assign(obj, {
        ...props,
        [__methods]: {
          id: () => createInterface (...props),
          toString: () => `Interface(${ props.toString() })`
        }
      })
      , missingMethod
    );
    

    const __methods = Symbol.for("__methods");
    
    const missingMethod = ({
        get: (obj, prop) => Reflect.has(obj, prop)
            ? Reflect.get(obj, prop)
            : Reflect.has(obj[__methods], prop)
                ? Reflect.get(obj[__methods], prop)
                : console.log(`No #${prop} property exists.`)
    });
    
        const obj = Object.create(null);
        const createInterface = (...props) => new Proxy(
          Object.assign(obj, {
            ...props,
            [__methods]: {
              id: () => createInterface (...props),
              toString: () => `Interface(${ props.toString() })`
            }
          })
          , missingMethod
        );
    
    const interface = createInterface(0, 1, 2);
    interface.id(); //works
    console.log(interface.toString());

    Another possibility would be for the getter to do a hasOwnProperty check instead of a Reflect.has check (Reflect.has is basically the same as in, and 'toString' will be in almost any object):

    get: (obj, prop) => obj.hasOwnProperty(prop)
    

    const __methods = Symbol.for("__methods");
    
    const missingMethod = ({
        get: (obj, prop) => obj.hasOwnProperty(prop)
            ? Reflect.get(obj, prop)
            : Reflect.has(obj[__methods], prop)
                ? Reflect.get(obj[__methods], prop)
                : console.log(`No #${prop} property exists.`)
    });
    const createInterface = (...props) => new Proxy({
        ...props,
        [__methods]: {
            id: () => createInterface (...props),
            toString: () => `Interface(${ props.toString() })`,
        }
    }, missingMethod);
    
    const interface = createInterface(0, 1, 2);
    interface.id(); //works
    console.log(interface.toString());

    A third possibility would be to make sure the property found by the initial Reflect.has is not from an Object.prototype method:

    get: (obj, prop) => Reflect.has(obj, prop) && Reflect.get(obj, prop) !== Object.prototype[prop]
    

    const __methods = Symbol.for("__methods");
    
    const missingMethod = ({
        get: (obj, prop) => Reflect.has(obj, prop) && Reflect.get(obj, prop) !== Object.prototype[prop]
            ? Reflect.get(obj, prop)
            : Reflect.has(obj[__methods], prop)
                ? Reflect.get(obj[__methods], prop)
                : console.log(`No #${prop} property exists.`)
    });
    
    const createInterface = (...props) => new Proxy({
        ...props,
        [__methods]: {
            id: () => createInterface (...props),
            toString: () => `Interface(${ props.toString() })`
        }
    }, missingMethod);
    
    const interface = createInterface(0, 1, 2);
    interface.id(); //works
    console.log(interface.toString());