Object.groupBy returns a null-prototype object
published
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.groupBy | manual reduce into {} | Map.groupBy | |
|---|---|---|---|
| Prototype of result | null | Object.prototype | Map.prototype |
result.hasOwnProperty(k) | throws TypeError | works | n/a (map.has(k)) |
Object.hasOwn(result, k) | works | works | n/a |
'toString' in result | false | true | n/a |
`${result}` | throws TypeError | [object Object] | [object Map] |
JSON.stringify(result) | works | works | {} — Maps do not serialize |
Group named __proto__ | kept as an own key | silently lost, prototype mutated | kept |
| Key types | strings and symbols only | strings and symbols only | any 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
- This is not a bug and it will not be “fixed”. The null prototype is the specified return value, so do not write code that waits for it to change.
- Availability. MDN marks both methods Baseline “newly available” since March 2024. Older runtimes do not have them at all — the failure there is
Object.groupBy is not a function, which is a different problem with a different fix. - Polyfills may not match. A polyfill is under no obligation to reproduce the null prototype exactly. If you ship one, check what it actually returns before relying on either the protection or the missing methods.
console.logoutput differs by host. The[Object: null prototype]prefix is Node’s inspector. Browser devtools show it differently, so do not use the log format as your detection mechanism — checkObject.getPrototypeOf(x) === null.- Values are always arrays. Every group is an array, even a group of one. There is no single-element shortcut to guard against.