Search code examples
javascriptecmascript-6prototypal-inheritance

Factory/class that extends native object


I'm trying to extend the built-in Map object. This is the part that works:

var Attributes = class extends Map {

    get text () {
        let out = [];
        for ( let [ k, v ] of this.entries() ) {
            out.push( k + '="' + v + '"' );
        }
        return out.join( ' ' );
    }

    // simplified for brevity!
    set text ( raw ) {
        this.clear();
        var m, r = /(\w+)="([^"]+)"/g;
        while ( ( m = r.exec( raw ) ) ) {
            this.set( m[1], m[2] );
        }
    }
};

var a = new Attributes();
a.text = 'id="first"';
console.log( a.get( 'id' ) ); // first
a.set( 'id', 'second' );
console.log( a.text ); // id="second"

But I want to integrate this class seamlessly into my library and rather expose a factory method that serves a double purpose as a constructor. Users don't need to know that this particular method is unusual. It would just complicate things. My own code however needs to be able to use instanceof for input validation purposes. This is what I'm going for:

var obj = {};
obj.attr = function ( text ) {
    if ( new.target ) {
        this.text = text;
    } else {
        return new obj.attr( text );
    }
};

console.log( obj.attr() instanceof obj.attr ); // true

The above, too, works. However, no matter how I try to combine the two approaches, both Chrome and Firefox throw various Errors. The code below e.g. throws a TypeError "this.entries(...)[Symbol.iterator] is not a function":

var obj = {};
obj.attr = function ( text ) {
    if ( new.target ) {
        this.text = text;
    } else {
        return new obj.attr( text );
    }
};

obj.attr.prototype = Object.assign( new Map(), {
    get text () {
        let out = [];
        for ( let [ k, v ] of this.entries() ) {
            out.push( k + '="' + v + '"' );
        }
        return out.join( ' ' );
    },
    // simplified for brevity!
    set text ( raw ) {
        this.clear();
        var m, r = /(\w+)="([^"]+)"/g;
        while ( ( m = r.exec( raw ) ) ) {
            this.set( m[1], m[2] );
        }
    }
} );

What am I missing and/or misunderstanding here?

Update: It would be possible using Object.setPrototypeOf() but the performance penalty would probably be substantial. It's also possible using Reflect.construct(). It's not obvious to me though how exactly this differs from Object.setPrototypeOf(). Reflect seems to be generally slow since apparently no current browser has optimizations for it.

var Attributes = class extends Map {
    
    constructor ( text ) {
        super();
        this.text = text;
    }
    
    get text () {
        let out = [];
        for ( let [ k, v ] of this.entries() ) {
            out.push( k + '="' + v + '"' );
        }
        return out.join( ' ' );
    }
    
    // simplified for brevity!
    set text ( raw ) {
        this.clear();
        if ( !raw ) return;
        var m, r = /(\w+)="([^"]+)"/g;
        while ( ( m = r.exec( raw ) ) ) {
            this.set( m[1], m[2] );
        }
    }
};

var obj = {};

obj.attr = function ( text ) {
    if ( new.target ) {
        return Reflect.construct( Attributes, [ text ], obj.attr );
    } else {
        return new obj.attr( text );
    }
};

obj.attr.prototype = Object.create( Attributes.prototype );

console.log( obj.attr() instanceof obj.attr );
console.log( obj.attr() instanceof Attributes );
console.log( obj.attr() instanceof Map );

var a = obj.attr();
a.text = 'id="first"';
console.log( a.get( 'id' ) );
a.set( 'id', 'second' );
console.log( a.text );


Solution

  • Its not working because Object.assign doesnt copy getters/setters, but calls them. So the getter (get text) is called with this being window , which will obviosly fail:

    MDN:

    The Object.assign() method only copies enumerable and own properties from a source object to a target object. It uses [[Get]] on the source and [[Set]] on the target, so it will invoke getters and setters. This may make it unsuitable for merging new properties into a prototype if the merge sources contain getters. For copying property definitions, including their enumerability, into prototypes Object.getOwnPropertyDescriptor() and Object.defineProperty() should be used instead.

    To copy the object into the Map without loosing the setters, may do:

    obj.attr.prototype = new Map();
    var settings = {
      get text(){},
      set text(v){}
    };
    
    for(key in settings){
      Object.defineProperty(
        obj.attr.prototype,
        key,
        Object.getOwnPropertyDescriptor(settings,key)
      );
    }
    

    However, this will still fail. Maps are different from objects. Calling this.clear() will crash as it expects this to be a map and not a regular object. So there are two options left:

    1) use the class syntax and a factory:

     {
      let internal = class extends Map {
       constructor(text){
         super();
         this.text = text;
       };
       set text(v){};
       get text(){};
      };
    
      var obj = {
       attr(text){
         return new internal(text);
        }
      };
    }
    

    2) keep the map internally:

     obj.attr = function(text){
       if(this === window) return new obj.attr(text);
       this.map = new Map();
       this.text = text;
    };
    obj.attr.prototype = {
      set text(v){
       this.map.set("text",v);
      },
      get text(){
       return this.map.get("text");
      }
    };