The destructuring algorithm in ECMAScript 6

[2015-03-12] esnext, dev, javascript
(Ad, please don’t block)

This blog post is outdated. Please read Sect. “The destructuring algorithm” in “Exploring ES6”.


This blog post looks at destructuring from a different angle: as a recursive matching algorithm. At the end, I’ll use this new knowledge to explain one especially tricky case of destructuring.

Destructuring  

This section gives a brief overview of destructuring. For further details, consult the blog post “Destructuring and parameter handling in ECMAScript 6”.

The following code is an example of destructuring:

let obj = { first: 'Jane', last: 'Doe' };
let { first: f, last: l } = obj; // (A)
    // f = 'Jane'; l = 'Doe'

In line (A) we destructure obj: we extract data from it via a pattern on the left-hand side of the assignment operator (=) and assign that data to the variables f and l. These variables are automatically declared beforehand, because the line starts with a let.

You can destructure arrays, too:

let [x, y] = ['a', 'b']; // x = 'a'; y = 'b'

Destructuring can be used in the following locations:

// Variable declarations:
let [x] = ['a'];
const [x] = ['a'];
var [x] = ['a'];

// Assignments:
[x] = ['a'];

// Parameter definitions:
function f([x]) { ··· }
f(['a']);

The algorithm  

The following is a destructuring assignment.

pattern = value

We want to use pattern to extract data from value. In the following sections, I describe an algorithm for doing so. It is known in functional programming as matching. The previous destructuring assignment is processed via

pattern ← value

That is, the operator (“match against”) matches pattern against value. The algorithm is specified via recursive rules that take apart both operands of the operator. The declarative notation may take some getting used to, but it makes the specification of the algorithm more concise. Each rule has two parts:

  • The head specifies which operands are handled by the rule.
  • The body specifies what to do next.

I only show the algorithm for destructuring assignment. Destructuring variable declarations and destructuring parameter definitions work similarly.

Patterns  

A pattern is either:

  • A variable: x
  • An object pattern: {«properties»}
  • An array pattern: [«elements»]

Each of the following sections covers one of these three cases.

Variables  

  • (1) x ← value (including undefined and null)

    x = value
    

Object patterns  

  • (2a) {«properties»} ← undefined

    throw new TypeError();
    
  • (2b) {«properties»} ← null

    throw new TypeError();
    
  • (2c) {key: pattern, «properties»} ← obj

    pattern ← obj.key
    {«properties»} ← obj
    
  • (2d) {key: pattern = default_value, «properties»} ← obj

    let tmp = obj.key;
    if (tmp !== undefined) {
        pattern ← tmp
    } else {
        pattern ← default_value
    }
    {«properties»} ← obj
    
  • (2e) {} ← obj (done)

Array patterns  

The sub-algorithm in this section starts with an array pattern and an iterable and continues with the elements of the pattern and an iterator (obtained from the iterable). The helper functions isIterable() and getNext() are defined at the end of this section.

  • (3a) [«elements»] ← non_iterable
    assert(!isIterable(non_iterable))

    throw new TypeError();
    
  • (3b) [«elements»] ← iterable assert(isIterable(iterable))

    let iterator = iterable[Symbol.iterator]();
    «elements» ← iterator
    
  • (3c) pattern, «elements» ← iterator

    pattern ← getNext(iterator)
    «elements» ← iterator
    
  • (3d) pattern = default_value, «elements» ← iterator

    let tmp = getNext(iterator);
    if (tmp !== undefined) {
        pattern ← tmp
    } else {
        pattern ← default_value
    }
    «elements» ← iterator
    
  • (3e) , «elements» ← iterator (hole, elision)

    getNext(iterator); // skip
    «elements» ← iterator
    
  • (3f) ...pattern ← iterator (always last part!)

    let tmp = [];
    for (let elem of iterator) {
        tmp.push(elem);
    }
    pattern ← tmp
    
  • (3g) ← iterator (no elements left, nothing to do)

The rules in this section use the following two helper functions:

function getNext(iterator) {
    let n = iterator.next();
    if (n.done) {
        return undefined;
    } else {
        return n.value;
    }
}
function isIterable(value) {
    return (value !== null
        && typeof value === 'object'
        && typeof value[Symbol.iterator] === 'function');
}

Using the algorithm  

The following function definition is used to make sure that both of the named parameters x and y have default values and can be omitted. Additionally, = {} enables us to omit the object literal, too (see last function call below).

function move({x=0, y=0} = {}) {
    return [x, y];
}
move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, 0]
move({}); // [0, 0]
move(); // [0, 0]

But why would you define the parameters as in the previous code snippet? Why not as follows – which is also completely legal ECMAScript 6?

function move({x, y} = { x: 0, y: 0 }) {
    return [x, y];
}

To see why solution 1 is correct, let’s use both solutions for two examples.

Using solution 2  

For function calls, formal parameters (inside function definitions) are matched against actual parameters (inside function calls). As an example, take the following function definition and the following function call.

function func(a=0, b=0) { ··· }
func(1, 2);

The parameters a and b are set up similarly to the following destructuring.

[a=0, b=0] ← [1, 2]

Let’s examine how destructuring works for move().

Example 1. move() leads to this destructuring:

[{x, y} = { x: 0, y: 0 }] ← []

The only array element on the left-hand side does not have a match on the right-hand side, which is why the default value is used (rules 3b, 3d):

{x, y} ← { x: 0, y: 0 }

The left-hand side contains property value shorthands, it is an abbreviation for:

{x: x, y: y} ← { x: 0, y: 0 }

This destructuring leads to the following two assignments (rule 2c, 1):

x = 0;
y = 0;

However, this is the only case in which the default value is used.

Example 2. Let’s examine the function call move({z:3}) which leads to the following destructuring:

[{x, y} = { x: 0, y: 0 }] ← [{z:3}]

There is an array element at index 0 on the right-hand side. Therefore, the default value is ignored and the next step is (rule 3d):

{x, y} ← { z: 3 }

That leads to both x and y being set to undefined, which is not what we want.

Using solution 1  

Let’s try solution 1.

Example 1: move()

[{x=0, y=0} = {}] ← []

We don’t have an array element at index 0 on the right-hand side and use the default value (rule 3d):

{x=0, y=0} ← {}

The left-hand side contains property value shorthands, which means that this destructuring is equivalent to:

{x: x=0, y: y=0} ← {}

Neither property x nor property y have a match on the right-hand side. Therefore, the default values are used and the following destructurings are performed next (rule 2d):

x ← 0
y ← 0

That leads to the following assignments (rule 1):

x = 0
y = 0

Example 2: move({z:3})

[{x=0, y=0} = {}] ← [{z:3}]

The first element of the array pattern has a match on the right-hand side and that match is used to continue destructuring (rule 3d):

{x=0, y=0} ← {z:3}

Like in example 1, there are no properties x and y on the right-hand side and the default values are used:

x = 0
y = 0

Conclusion  

The examples demonstrate that default values are a feature of pattern parts (object properties or array elements). If a part has no match or is matched against undefined then the default value is used. That is, the pattern is matched against the default value, instead.