Object.assign() causes TypeError: this.map is not a function

Viewed 102

I was trying to extend Array.prototype with some new methods/properties, the following is what I did in the first attempt, which ended up with a TypeError:

// Array.prototype + .max(), .maxRowLength (by Object.assign)
Object.assign(Array.prototype, {

    // max something in the array
    max(mapf = (x) => x) {
        // -----------------------------------------
        return Math.max(...this.map(x => mapf(x)));
        //                 ^^^^^^^^
        // ⛔ TypeError: this.map is not a function
        // -----------------------------------------
    },

    // max row length of 2D array
    // assuming an element of the 2D array is called a "row"
    get maxRowLength() {
        return this.max(row => row.length);
    },
});

// matrix (2D array)
let m = [[1, 2, 3, 4], [1, 2], [1, 2, 3]];

m.max(row => row.length)    // didn't work, can't even run.
m.maxRowLength

I don't understand why this is happening, so I go on with my second attempt, this time with a different approach, and it runs successfully without any problem:

// Array.prototype + .max()
Array.prototype.max = function(f = x => x){
    return Math.max(...this.map(x => f(x)));
};

// Array.prototype + .maxRowLength (by Object.defineProperty)
Object.defineProperty(Array.prototype, 'maxRowLength', {
    get: function(){ return this.max(row => row.length) }
});

// matrix (2D array)
let m = [[1, 2, 3, 4], [1, 2], [1, 2, 3]];    // 3 "rows"

m.max(row => row.length)    // 4 ✅
m.maxRowLength              // 4 ✅

Does anyone know what are the differences between these two approaches, and most importantly why the first attampt failed?

2 Answers

In your first code, when you use Object.assign, the parameters past the first have their getters invoked:

Object.assign(
  {},
  { get foo() { console.log('getter invoked'); } }
);

So, your get maxRowLength is running immediately, before you're even declaring the m array - and when the getter is invoked, it calls this.max, and at that point, this is the second argument you're passing to Object.assign, which is that object - which doesn't have a .map method, resulting in the error.

Here's another more minimal example of your code to show what's going on:

const objectToAssign = {
    max() {
        // `this` is the object here
        console.log(this === objectToAssign);
    },
    get maxRowLength() {
        return this.max();
    },
};
Object.assign(Array.prototype, objectToAssign);

Yesterday when I was reading the book JavaScript: The Definitive Guide, 7th Edition, I suddenly came across a listing of code in that book (Section 14.1: Property Attributes) that happened to be dedicated exactly to my original problem, so I think maybe I should list it here for future reference or maybe someone may need it:

/*****************************************************************
 *                 Object.assignDescriptors()                    *
 *****************************************************************
 *
 * • copies property descriptors from sources into the target
 *   instead of just copying property values. 
 * 
 * • copies all own properties (both enumerable and non-enumerable).
 * • copies getters from sources and overwrites setters in the target
 *   rather than invoking those getters/setters.
 *
 * • propagates any TypeErrors thrown by `Object.defineProperty()`:
 *   • if the target is sealed or frozen or 
 *   • if any of the source properties try to change an existing
 *     non-configurable property on the target.
 */
Object.defineProperty(Object, "assignDescriptors", {
    
    // match the attributes of `Object.assign()`
    writable    : true,
    enumerable  : false,
    configurable: true,

    // value of the `assignDescriptors` property.
    value: function(target, ...sources) {
        
        for(let source of sources) { 
            // copy properties
            for(let name of Object.getOwnPropertyNames(source)) { 
                let desc = Object.getOwnPropertyDescriptor(source, name);
                Object.defineProperty(target, name, desc); 
            }
            // copy symbols
            for(let symbol of Object.getOwnPropertySymbols(source)) { 
                let desc = Object.getOwnPropertyDescriptor(source, symbol); 
                Object.defineProperty(target, symbol, desc); 
            }
        }
        
        return target;
    } 
});

// a counter
let counter = {
    _c: 0, 
    get count() { return ++this._c; }    // ⭐ getter
}; 

// ----------------------------------------------------------
// ☢️ Alert:
//    Don't use Object.assign with sources that have getters,
//    the inner states of the sources may change❗❗❗
// ----------------------------------------------------------

// copy the property values (with getter)
let iDontCount = Object.assign({}, counter);             
// ☢️ `counter.count` gets INVOKED❗❗❗ counter._c === 1 (polluted)❗❗❗

// copy the property descriptors 
let iCanCount = Object.assignDescriptors({}, counter);  

[
    counter._c,              // 1 (☢️ polluted by Object.assign❗)
    
    // ⭐ `iDontCount.count` is a "data" property
    Object.getOwnPropertyDescriptor(iDontCount, 'count'),
    // { 
    //   value: 1, <---- ⭐ data property
    //   writable: true, enumerable: true, configurable: true
    // }
    
    iDontCount.count,        // 1: just a data property,
    iDontCount.count,        // 1: it won't count.
    
    // ⭐ `iCanCount.count` is an "accessor" property (getter) 
    Object.getOwnPropertyDescriptor(iCanCount, 'count'),
    // {
    //   get: [Function: get count], <---- ⭐ accessor property
    //   set: undefined,
    //   enumerable: true,
    //   configurable: true
    // }
    
    // ☢️ although it can count, it doesn't count from 1❗
    iCanCount.count,    // 2: it's a getter method alright, but its "count"
    iCanCount.count,    // 3: has been polluted by Object.assgin() ☢️ 
    
].forEach(x => console.log(x))

Related