In this chapter, we look at destructuring from a different angle: as a recursive pattern matching algorithm.
The algorithm will give us a better understanding of default values. That will be useful at the end, where we’ll try to figure out how the following two functions differ:
A destructuring assignment looks like this:
We want to use pattern
to extract data from value
.
We will now look at an algorithm for performing this kind of assignment. This algorithm is known in functional programming as pattern matching (short: matching). It specifies the operator ←
(“match against”) that matches a pattern
against a value
and assigns to variables while doing so:
We will only explore destructuring assignment, but destructuring variable declarations and destructuring parameter definitions work similarly. We won’t go into advanced features, either: Computed property keys, property value shorthands, and object properties and array elements as assignment targets, are beyond the scope of this chapter.
The specification for the match operator consists of declarative rules that descend into the structures of both operands. The declarative notation may take some getting used to, but it makes the specification more concise.
The declarative rules used in this chapter operate on input and produce the result of the algorithm via side effects. This is one such rule (which we’ll see again later):
(2c) {key: «pattern», «properties»} ← obj
This rule has the following parts:
In rule (2c), the head means that this rule can be applied if there is an object pattern with at least one property (whose key is key
) and zero or more remaining properties. The effect of this rule is that execution continues with the property value pattern being matched against obj.key
and the remaining properties being matched against obj
.
Let’s consider one more rule from this chapter:
(2e) {} ← obj
(no properties left)
In rule (2e), the head means that this rule is executed if the empty object pattern {}
is matched against a value obj
. The body means that, in this case, we are done.
Together, rule (2c) and rule (2e) form a declarative loop that iterates over the properties of the pattern on the left-hand side of the arrow.
The complete algorithm is specified via a sequence of declarative rules. Let’s assume we want to evaluate the following matching expression:
{first: f, last: l} ← obj
To apply a sequence of rules, we go over them from top to bottom and execute the first applicable rule. If there is a matching expression in the body of that rule, the rules are applied again. And so on.
Sometimes the head includes a condition that also determines if a rule is applicable – for example:
(3a) [«elements»] ← non_iterable
if (!isIterable(non_iterable))
A pattern is either:
x
{«properties»}
[«elements»]
The next three sections specify rules for handling these three cases in matching expressions.
x ← value
(including undefined
and null
)(2a) {«properties»} ← undefined
(illegal value)
(2b) {«properties»} ← null
(illegal value)
(2c) {key: «pattern», «properties»} ← obj
(2d) {key: «pattern» = default_value, «properties»} ← obj
(2e) {} ← obj
(no properties left)
Rules 2a and 2b deal with illegal values. Rules 2c–2e loop over the properties of the pattern. In rule 2d, we can see that a default value provides an alternative to match against if there is no matching property in obj
.
Array pattern and iterable. The algorithm for Array destructuring starts with an Array pattern and an iterable:
(3a) [«elements»] ← non_iterable
(illegal value)
if (!isIterable(non_iterable))
(3b) [«elements»] ← iterable
if (isIterable(iterable))
Helper function:
function isIterable(value) {
return (value !== null
&& typeof value === 'object'
&& typeof value[Symbol.iterator] === 'function');
}
Array elements and iterator. The algorithm continues with:
These are the rules:
(3c) «pattern», «elements» ← iterator
(3d) «pattern» = default_value, «elements» ← iterator
(3e) , «elements» ← iterator
(hole, elision)
(3f) ...«pattern» ← iterator
(always last part!)
(3g) ← iterator
(no elements left)
Helper function:
function getNext(iterator) {
const {done,value} = iterator.next();
return (done ? undefined : value);
}
An iterator being finished is similar to missing properties in objects.
Interesting consequence of the algorithm’s rules: We can destructure with empty object patterns and empty Array patterns.
Given an empty object pattern {}
: If the value to be destructured is neither undefined
nor null
, then nothing happens. Otherwise, a TypeError
is thrown.
const {} = 123; // OK, neither undefined nor null
assert.throws(
() => {
const {} = null;
},
/^TypeError: Cannot destructure 'null' as it is null.$/)
Given an empty Array pattern []
: If the value to be destructured is iterable, then nothing happens. Otherwise, a TypeError
is thrown.
const [] = 'abc'; // OK, iterable
assert.throws(
() => {
const [] = 123; // not iterable
},
/^TypeError: 123 is not iterable$/)
In other words: Empty destructuring patterns force values to have certain characteristics, but have no other effects.
In JavaScript, named parameters are simulated via objects: The caller uses an object literal and the callee uses destructuring. This simulation is explained in detail in “JavaScript for impatient programmers”. The following code shows an example: function move1()
has two named parameters, x
and y
:
function move1({x=0, y=0} = {}) { // (A)
return [x, y];
}
assert.deepEqual(
move1({x: 3, y: 8}), [3, 8]);
assert.deepEqual(
move1({x: 3}), [3, 0]);
assert.deepEqual(
move1({}), [0, 0]);
assert.deepEqual(
move1(), [0, 0]);
There are three default values in line A:
x
and y
.move1()
without parameters (as in the last line).But why would we define the parameters as in the previous code snippet? Why not as follows?
To see why move1()
is correct, we are going to use both functions in two examples. Before we do that, let’s see how the passing of parameters can be explained via matching.
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.
The parameters a
and b
are set up similarly to the following destructuring.
move2()
Let’s examine how destructuring works for move2()
.
Example 1. The function call move2()
leads to this destructuring:
The single Array element on the left-hand side does not have a match on the right-hand side, which is why {x,y}
is matched against the default value and not against data from the right-hand side (rules 3b, 3d):
The left-hand side contains property value shorthands. It is an abbreviation for:
This destructuring leads to the following two assignments (rules 2c, 1):
This is what we wanted. However, in the next example, we are not as lucky.
Example 2. Let’s examine the function call move2({z: 3})
which leads to the following destructuring:
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):
That leads to both x
and y
being set to undefined
, which is not what we want. The problem is that {x,y}
is not matched against the default value, anymore, but against {z:3}
.
move1()
Let’s try move1()
.
Example 1: move1()
We don’t have an Array element at index 0 on the right-hand side and use the default value (rule 3d):
The left-hand side contains property value shorthands, which means that this destructuring is equivalent to:
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):
That leads to the following assignments (rule 1):
Here, we get what we want. Let’s see if our luck holds with the next example.
Example 2: move1({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):
Like in example 1, there are no properties x
and y
on the right-hand side and the default values are used:
It works as desired! This time, the pattern with x
and y
being matched against {z:3}
is not a problem, because they have their own local default values.
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.