ESC

Type to search the knowledge base.

Object.keys Polyfill

Polyfill Object.keys: own enumerable string keys in insertion order, no symbols, and integer-index keys listed before other strings.

intermediate5 min read
  • javascript
  • objects
  • polyfill
  • interview

for...in is not Object.keys. for...in walks the prototype and, historically, did not promise order. Object.keys is own enumerable string keys only. Integer-index keys (canonical array indexes) come first, ascending numeric. Other strings follow in insertion order. Symbols never appear. A polyfill that returns Object.getOwnPropertyNames includes hidden keys. A polyfill that returns Reflect.ownKeys includes symbols too.

Write a standalone myKeys(value). Assigning Object.keys = myKeys is interview convention and mutates a shared builtin.

Key takeaways

  • Own enumerable strings only. No inherited keys, no non-enumerable keys, no symbols.
  • Array-index keys first in numeric order, then the rest in insertion order.
  • null and undefined throw. Other primitives box: Object.keys('ab') is ['0', '1'].
  • getOwnPropertyNames adds non-enumerable strings. Reflect.ownKeys adds symbols.
  • for...in without hasOwn copies the prototype chain.

Enumerable string keys, insertion order, no symbols

EnumerableOwnPropertyNames filters [[OwnPropertyKeys]] to enumerable strings. OrdinaryOwnPropertyKeys is the order: array indexes ascending, remaining strings by creation time, then symbols (which keys drops).

function isArrayIndex(key) {
  if (typeof key !== 'string') return false;
  const n = Number(key);
  return n >= 0 && n < 0xffffffff && String(n) === key && Number.isInteger(n);
}

function myKeys(value) {
  if (value === null || value === undefined) {
    throw new TypeError('Cannot convert undefined or null to object');
  }

  const O = Object(value);
  const integerKeys = [];
  const otherKeys = [];

  for (const key in O) {
    if (!Object.prototype.hasOwnProperty.call(O, key)) continue;
    if (isArrayIndex(key)) integerKeys.push(key);
    else otherKeys.push(key);
  }

  integerKeys.sort((a, b) => a - b);
  return integerKeys.concat(otherKeys);
}

const sample = { z: 1, 2: 'c', a: 2, 0: 'a', 1: 'b' };
myKeys(sample); // ['0', '1', '2', 'z', 'a']

'2' was inserted before '0'. Numeric order still wins for array indexes. 'z' was inserted before 'a', and neither is an index, so insertion order stays. '01' is not canonical (String(Number('01')) is '1'), so it is a regular string and does not sort with 1. '-1' is not an array index.

hasOwnProperty must be borrowed. O.hasOwnProperty(key) fails if the object has its own hasOwnProperty or a null prototype (Object.create(null) has no methods). Object.prototype.hasOwnProperty.call is the usual fix. Object.hasOwn is the modern name.

What each enumerator actually returns

API Inherited Non-enumerable strings Symbols Order
Object.keys no no no indexes, then insertion
Object.getOwnPropertyNames no yes no same string order
Reflect.ownKeys no yes yes strings, then symbols
for...in yes (enumerable) no no engine; filter with hasOwn
const hidden = Symbol('hidden');
const proto = { inherited: 1 };
const obj = Object.create(proto, {
  visible: { value: 2, enumerable: true },
  quiet: { value: 3, enumerable: false },
  [hidden]: { value: 4, enumerable: true },
});
obj.own = 5;

myKeys(obj);                          // ['visible', 'own']  (insertion)
Object.getOwnPropertyNames(obj);      // includes 'quiet'
Reflect.ownKeys(obj);                 // includes hidden symbol

If the interviewer allows getOwnPropertyNames, the order-faithful body is: names, then keep those whose descriptor is enumerable. That still drops symbols. It is closer to the spec than for...in because it never consults the prototype.

function myKeysFromNames(value) {
  if (value === null || value === undefined) {
    throw new TypeError('Cannot convert undefined or null to object');
  }
  const O = Object(value);
  return Object.getOwnPropertyNames(O).filter((key) => {
    const desc = Object.getOwnPropertyDescriptor(O, key);
    return desc && desc.enumerable;
  });
}

getOwnPropertyNames already returns indexes first, then other strings. You do not sort again unless you built the list yourself.

Procedure

  1. Throw if the argument is null or undefined. Box everything else with Object(...).
  2. Collect own enumerable string keys. Borrow hasOwn if you are on for...in.
  3. Split array-index keys from the rest. Sort the indexes as numbers ('10' after '2').
  4. Concatenate: indexes, then other strings in the order you first saw them.
  5. Leave symbols and non-enumerable names out. Do not walk the prototype.

Class instances follow the same rule. Own enumerable fields show up. Methods on the prototype do not. That is why Object.keys(new Map()) is [] and Object.keys({ size: 1 }) is ['size'].

Footguns in an Object.keys polyfill

Object.keys(null) and Object.keys(undefined) throw. Object.keys(3) is []. Object.keys('ab') is ['0', '1'] because a boxed String has enumerable index keys. Object.keys([, 'x']) is ['1']: the hole is not enumerable because it is not a property.

Getters are not run. keys returns names, not values. Values are entries.

Integer-index sort is numeric: '10' after '2'. Lexicographic sort (['10','2'].sort()) is the bug that prints '10' first.

Do not use Object.keys to implement myKeys. The for...in + hasOwn version is the no-helper walk. Mention that old engines’ for...in order was messy; the sort of array indexes is how you state the current spec out loud.

JSON.stringify walks enumerable string keys and also skips symbols. It omits object values that are undefined or functions. Object.keys still returns those keys: enumerability is about the property, not the value.

Patching Object.keys mutates a builtin. Object.assign is a consumer of this list (string keys; symbols are extra credit there).

Interview answer (out loud)

“Object.keys boxes the argument, throws on null and undefined, and returns own enumerable string keys. Array-index keys come first in numeric order, then other strings in insertion order. I skip symbols, inherited keys, and non-enumerable names. for-in needs a borrowed hasOwn check or it walks the prototype. getOwnPropertyNames is the same strings plus hidden ones. Reflect.ownKeys adds symbols.”

Further reading

Related guides