~/blog

Integer-like object keys jump ahead of insertion order

published

#javascript#ecmascript#json

TL;DR

Object property order is insertion order only for keys that are not array indices. Any key that is a canonical integer string in the range 0 to 2^32 - 2 is hoisted to the front and sorted numerically, by Object.keys(), for...in, JSON.stringify(), object spread and Object.entries() alike. If your keys are numeric ids, the object silently re-sorts itself. Use a Map, or prefix the keys so they stop looking like indices.

The problem

You build a lookup keyed by id, in the order the API returned it:

const byId = {};
byId['1001'] = 'Ada';
byId['42'] = 'Grace';
byId['zed'] = 'Alan';

console.log(Object.keys(byId));
[ '42', '1001', 'zed' ]

42 was inserted second and comes out first. The same reordering survives serialization, which is where it usually gets noticed — a fixture file, a snapshot test or an API payload comes back in an order nobody wrote:

JSON.stringify(byId);
// '{"42":"Grace","1001":"Ada","zed":"Alan"}'

Spread does not rescue it either — { ...byId } copies properties by the same ordering rules, so the clone has the same reordered layout.

Why it happens

The spec’s OrdinaryOwnPropertyKeys defines a fixed three-part order for own properties:

  1. Array-index keys, in ascending numeric order.
  2. Other string keys, in property-creation order.
  3. Symbol keys, in property-creation order.

An array index is narrower than “a key that looks like a number”. It must be the canonical string form of an integer in the range 0 to 2^32 - 2. That excludes more than people expect:

KeyArray index?Where it lands
'42'yesfront block, numeric order
'4294967294'yes (2^32 - 2, the last valid index)front block
'4294967295'no (2^32 - 1 is out of range)insertion order
'01'no (not canonical — String(1) is '1')insertion order
'1.0'no (not canonical)insertion order
'-1'no (negative)insertion order

Run all of them through one object and the split is visible:

const o = {};
for (const k of ['1001', '42', 'zed', '-1', '01', '4294967295', '4294967294']) o[k] = true;

console.log(Object.keys(o));
// [ '42', '1001', '4294967294', 'zed', '-1', '01', '4294967295' ]

Three integer-ish keys sorted to the front; the other four kept the order they were written in, including the two that merely look numeric.

This is not an engine quirk to be fixed. Property order was left implementation-defined for years, and when TC39 standardised it in ES2015 it standardised the behaviour engines already had, which existed so that objects with dense numeric keys could be stored the way arrays are.

What to do

Use a Map when the keys are data. Map preserves insertion order for every key type, and it does not coerce keys to strings:

const byId = new Map([
  [1001, 'Ada'],
  [42, 'Grace'],
]);

[...byId.keys()]; // [ 1001, 42 ]

If it must stay a plain object — because it is JSON on the wire — stop the keys from looking like indices, or stop depending on order:

// 1. prefix: 'id:1001' is not an array index, so insertion order holds
const byId = { 'id:1001': 'Ada', 'id:42': 'Grace' };

// 2. carry the order explicitly, which survives any serializer
const payload = { order: ['1001', '42'], items: { 1001: 'Ada', 42: 'Grace' } };

// 3. use an array of entries when order IS the meaning
const rows = [{ id: 1001, name: 'Ada' }, { id: 42, name: 'Grace' }];

Sort at read time rather than trusting the object. If you only need a stable order for display or for a diff, sort explicitly — an explicit comparator is also the only way to get numeric order for keys above 2^32 - 2:

Object.keys(byId).sort((a, b) => Number(a) - Number(b));

Caveats

References