~/blog

Object.groupBy returns a null-prototype object

published

#javascript#ecmascript

TL;DR

Object.groupBy() does not return a plain object. It returns a null-prototype object, so result.hasOwnProperty(k) throws TypeError: result.hasOwnProperty is not a function, `${result}` throws TypeError: Cannot convert object to primitive value, and 'toString' in result is false. Use Object.hasOwn(), Object.keys(), and JSON.stringify() — those all work. The missing prototype is deliberate: it is what stops a group named __proto__ from destroying your result.

The problem

You group some records, then check whether a group exists the way you have checked for the last fifteen years:

const inventory = [
  { type: 'fruit', name: 'apple' },
  { type: 'veg', name: 'kale' },
  { type: 'fruit', name: 'fig' },
];

const byType = Object.groupBy(inventory, (item) => item.type);

if (byType.hasOwnProperty('fruit')) {
  console.log('we have fruit');
}
TypeError: byType.hasOwnProperty is not a function

The grouping worked. The object is fine. It simply has no Object.prototype behind it, so none of the methods you inherit for free are there.

Node prints the object with a marker that tells you exactly what happened, which is the fastest way to diagnose this in a REPL:

> console.log(byType)
[Object: null prototype] {
  fruit: [ { type: 'fruit', name: 'apple' }, { type: 'fruit', name: 'fig' } ],
  veg: [ { type: 'veg', name: 'kale' } ]
}

The second failure is nastier because it hits in string contexts rather than at a method call:

`${byType}`;      // TypeError: Cannot convert object to primitive value
'' + byType;      // same
byType.toString;  // undefined
'toString' in byType; // false

Any code path that stringifies the result — a log line built with a template literal, an error message, a naive cache key — throws instead of producing [object Object].

Why it happens

MDN states the return value plainly: “A null-prototype object with properties for all groups, each assigned to an array containing the elements of the associated group.”

The reason is that the keys come from your data, not from your source code. The callback returns a value that gets coerced to a property key, and that value is usually pulled off a record that arrived over the network. If Object.groupBy built an ordinary object, a record whose group name is __proto__ would not be grouped — it would mutate the result:

const rows = [{ g: '__proto__', id: 1 }];

// what an ordinary object does with that key
const ordinary = {};
ordinary['__proto__'] = [rows[0]];
Object.keys(ordinary);              // []            <- the group vanished
Object.getPrototypeOf(ordinary);    // the array     <- prototype replaced

// what Object.groupBy does
const grouped = Object.groupBy(rows, (r) => r.g);
Object.keys(grouped);               // [ '__proto__' ]
Object.hasOwn(grouped, '__proto__'); // true

The same argument applies to toString, constructor, valueOf and every other inherited name: on a null-prototype object a group called toString is just a key, not a collision with a method.

The three grouping shapes, side by side

Object.groupBymanual reduce into {}Map.groupBy
Prototype of resultnullObject.prototypeMap.prototype
result.hasOwnProperty(k)throws TypeErrorworksn/a (map.has(k))
Object.hasOwn(result, k)worksworksn/a
'toString' in resultfalsetruen/a
`${result}`throws TypeError[object Object][object Map]
JSON.stringify(result)worksworks{} — Maps do not serialize
Group named __proto__kept as an own keysilently lost, prototype mutatedkept
Key typesstrings and symbols onlystrings and symbols onlyany value

Map.groupBy is the right call when the group key is an object, a number you do not want stringified, or anything else that is not naturally a property name. It is not a workaround for the prototype issue — it is a different data structure with its own trade-off, namely that JSON.stringify of a Map gives you {}.

What to do

Replace the inherited-method calls with the static equivalents. All of these work on a null-prototype object:

const byType = Object.groupBy(inventory, (item) => item.type);

Object.hasOwn(byType, 'fruit');   // true   — replaces hasOwnProperty
Object.keys(byType);              // ['fruit', 'veg']
Object.entries(byType);           // [['fruit', [...]], ['veg', [...]]]
JSON.stringify(byType);           // works, plain JSON object
byType.fruit?.length ?? 0;        // property access is completely normal

If some downstream code genuinely needs a normal object — an older library that calls result.hasOwnProperty, or anything that stringifies it — spread it once at the boundary. Spreading produces an ordinary object with Object.prototype:

const plain = { ...byType };
Object.getPrototypeOf(plain) === Object.prototype; // true

Do that at the edge, not immediately after grouping. The moment you spread, the __proto__ protection is gone: a group named __proto__ will be swallowed by the spread exactly as in the example above.

For a stable public API, prefer returning the Map:

const byType = Map.groupBy(inventory, (item) => item.type);
byType.has('fruit');            // true
byType.get('fruit').length;     // 2

Caveats

References