2015-10-24

The traversal order of object properties in ES6

The ECMAScript 6 specification defines in which order the properties of an object should be traversed. This blog post explains the details.

Why specify the traversal order?

Traditionally, a JavaScript object is basically a map from strings to arbitrary values. The inherent nature of such a data structure is for entries to be unordered, which explains why, for a long time, the order in which properties are traversed was left unspecified in JavaScript (to be handled by engines as they saw fit).

However, most engines ended up having the same order and now code depends on it. Therefore, using a different order breaks web apps, which is why requiring an order makes sense.

In general, there are two possible approaches for preventing code from breaking in the manner that I’ve just described:

  1. Specify an order that code can depend on.
  2. Specify that engines must make it impossible for code to rely on an order, by choosing a different order each time.

The latter is hard, which is why the former approach was taken. An additional benefit is that it helps with programming tasks such as debugging and testing output, where a fixed order makes it easier to compare expected with actual results. For example, JSON.stringify(obj) will always produce the same result, as long as obj is created in the same manner.

Operations that traverse properties

The following operations in ECMAScript 6 traverse the keys of properties (the only way in which you can currently iterate over properties):

  • Own property keys:
    • Object.keys()
    • Object.getOwnPropertyNames()
    • Reflect.ownKeys()
  • All (own and inherited) keys:
    • Reflect.enumerate()
    • for-in loop

Traversing the own keys of an object

Property keys are traversed in the following order:

  • First, the keys that are integer indices (what these are is explained later), in ascending numeric order.
  • Then, all other string keys, in the order in which they were added to the object.
  • Lastly, all symbol keys, in the order in which they were added to the object.

Many engines treat integer indices specially (even though they are still strings, at least as far as the ES6 spec is concerned). Therefore, it makes sense to treat them as a separate category of keys.

Integer indices

Roughly, an integer index is a string that, if converted to a 53-bit non-negative integer and back is the same value. Therefore:

  • '10' and '2' are integer indices.
  • '02' is not an integer index. Coverting it to an integer and back results in the different string '2'.
  • '3.141' is not an integer index, because 3.141 is not an integer.

In ES6, instances of String and Typed Arrays have integer indices. The indices of normal Arrays are a subset of integer indices: they have a smaller range of 32 bits. For more information on Array indices, consult “Array Indices in Detail” in “Speaking JavaScript”.

Example

The following code demonstrates the order in which the own keys of an object are iterated over:

    let obj = {
        [Symbol('first')]: true,
        '02': true,
        '10': true,
        '01': true,
        '2': true,
        [Symbol('second')]: true,
    }
    Reflect.ownKeys(obj);
    [ '2', '10', '02', '01', Symbol('first'), Symbol('second') ]

Explanation:

  • '2' and '10' are integer indices, come first and are sorted numerically.
  • '02' and '01' are normal string keys, come next and appear in the order in which they were added to obj.
  • Symbol('first') and Symbol('second') are symbols and come last, in the order in which they were added to obj.

Enumerating the string keys of all enumerable properties

The for-in loop and its iterator-returning analog, Reflect.enumerate() traverse the keys of all properties, not just the own ones. But they only consider enumerable string keys.

Algorithm: In order to enumerate the property keys of an object obj, ...

  • Visit the keys of all own enumerable string keys of obj, in the order described in the previous section. Ignore any keys that existed in previously processed objects (independently of whether those were keys of enumerable properties or not).

  • Perform the previous step for the prototype of obj, the prototype’s prototype, etc., until the end of the chain is reached.

The ES6 spec contains a recursive implementation that stays close to the ES6 meta object protocol. This is an iterative version of that implementation that is slightly easier to understand:

    function* enumerate(obj) {
        let visited = new Set();
        while (obj) {
            for (let key of Reflect.ownKeys(obj)) {
                if (typeof key === 'string') {
                    let desc = Reflect.getOwnPropertyDescriptor(obj, key);
                    if (desc && !visited.has(key)) {
                        visited.add(key);
                        if (desc.enumerable) {
                            yield key;
                        }
                    }
                }
            }
            obj = Reflect.getPrototypeOf(obj);
        }
    }

The following code demonstrates that for-in and Reflect.enumerate() ignore properties they encountered before, even if they weren’t enumerated.

    let proto = Object.defineProperty({},
        'foo', {
            // Ignored, because key was already encountered
            enumerable: true,
        });
    let object = Object.create(proto, {
        foo: {
            // Not enumerated
            enumerable: false,
        },
    });
    console.log([...Reflect.enumerate(object)]); // []

Conclusion

Being able to rely on the order in which properties are traversed in ES6 will help with several tasks (such as testing).

However, I expect that with ES6 Maps, one will traverse properties of objects less often. As an aside, the entries of Maps are ordered, too, by when they were added. The rationale for doing so is they same as the one for objects (as described earlier).

Sources

Two threads on es-discuss are instructive w.r.t. the traversal order of properties:

No comments: