Array destructuring - revisited
This article was originally published on medium.com/@P0lip.
Destructuring assignment is undoubtedly one of the most beloved features, added as a part of ES2015 specification. Not only it lets you type less, but it also makes your code look by far cleaner.
Its use is simply ubiquitous in every single ES2015+ project these days. We can distinguish two kinds of destructuring assignment patterns:
- object (ObjectAssignmentPattern)
- and array (ArrayAssignmentPattern).
The former, I believe, is more popular. However, with the introduction of React Hooks, the latter will become more ubiquitous either. Due to the smaller popularity of array destructuring, some articles about React Hooks describe briefly how array destructuring works.
This article won't be any different, but instead of focusing on the general usage, I will try to shed some light on a lesser-known side of array pattern destructuring.
One of the common misconceptions about array destructuring I noticed is the statement that array destructuring works only for arrays. While it's certainly the most popular use case, it's not true at all. In fact, 'Array' phrase describes the left-hand side expression of the entire assignment expression - an array literal, and not the actual value, which doesn't even have to be an array but an iterable, as we will learn shortly.
The same applies to object pattern destructuring. They both are meant to describe the left-hand side expression of the assignment expression.
In fact, to assign array values, we can even use object pattern destructuring as follows:
const { 0: firstItem, 1: secondItem } = ['foo', 'bar'];
If we print out firstItem
or secondItem
, we can clearly observe, the first
and the second item were assigned according to (or not?) our expectations. As
you can see, object pattern destructuring works quite well with arrays as
well.
One may ask what makes an array pattern destructuring different. Let's dive in and search for an answer in the specification.
We can clearly notice that unlike ObjectAssignmentPattern (so-called object destructuring), all ArrayAssignmentPattern (so-called array destructuring) accesses iterator (as described in getIterator) as a first step of the evaluation process and then performs iterates over a retrieved iterator.
Before we move one, we need to learn what an iterator and an iterable are and how they affect array destructuring. To keep it simple:
- iterable — an object implementing @@iterator method which returns an Iterator object.
- iterator — an object implementing next method, which returns done property and optionally a value. May throw.
A more precise description can be read in the linked documentation. If you want to read about it in greater detail, MDN has you covered.
It's worth mentioning that a generator object is both an iterable and an iterator. As of ECMAScript 2018, we can also distinguish an async iterable, an async iterator, and an async generator object.
Alright, let's start actual coding. Here, we've got an object literal that implements the @@iterator method.
const fooBarObj = { *[Symbol.iterator]() { yield 'foo'; yield 'bar'; },};
Let's try to combine that with destructuring and see what we get.
const [foo, bar] = fooBarObj;// foo equals 'foo'// bar equals 'bar'
Pretty neat, huh?
One may observe that the foo and bar depend on the actual result of the iteration. This tells us something more, as we will find out shortly, yet before we start digging further, we have to answer quite an important question. Why does array pattern destructuring work for arrays, then? Is an array an iterable? Yes, it is. In order to verify the correctness of our words, we can yet again visit the spec or in case you don't want to do that for some reason, you can also check it out manually as follows:
Array.prototype[Symbol.iterator];
As you can see, Array.prototype.values
is returned, which, I think, makes it
self-explanatory why array pattern assignment works for arrays in the way we use
it on our daily basis. If we re-assigned that iterator, we would get way
different results, i.e.
Array.prototype[Symbol.iterator] = Array.prototype.keys;const [firstIndex, secondIndex] = ['a', 'b'];// firstIndex equals 0, secondIndex equals 1
Are there any other native objects that are iterables? Well, a handful, in fact. String is one of the examples, but also keyed collections (Map, Set) and most likely some other objects I don't recall. There is also a vast amount of different methods for accepting iterables.
The interesting bit here is that we can also implement an iterable method. As always, an example says more than thousands of words.
const balticCountries = ['Estonia', 'Latvia', 'Lithuania'];const scandinavianCountries = ['Norway', 'Sweden', 'Denmark'];Boolean.prototype[Symbol.iterator] = function* () { yield* balticCountries; yield 'Finland'; yield* scandinavianCountries;};const [, , , theyDrinkVodka] = false;
Guess what's printed out ;)
So summing up - array pattern destructuring works for iterables, and each item points to the respective result of iteration.
As a side fact, there are more cases that may invoke iterator protocol. These include yield*, for of loop or spread operators (in some cases, i.e. for argument list or in an array literal initializer).
Please bear in mind that the following examples are meant to show the language capabilities - you shouldn't be writing such code unless you want to annoy your colleagues and/or have an unmaintainable code.
Let's take the previous example and do the following:
for (const country of true) { console.log(country);}
as you could expect, Baltic and Scandinavian countries were logged as well as Finland.
Let's try out the spread operator within an array initializer.
[...false];
Same result. What about yield*?
function* listCountries() { yield* false;}const countriesIt = listCountries();while (true) { const { value, done } = countriesIt.next(); if (done === true) break; console.log(value);}
Alright, same story.
Moreover, there is also a couple of built-in functions that accept iterables,
i.e. Promise.all
, Promise.race
, keyed collections constructors or
Array.from
.
Wrapping all our gathered knowledge together, let's code a more realistic example. Imagine we deal with a data structure different than the array, i.e. linked list or tree.
Our class or object instance could simply be an iterable, as shown in the example below.
class SinglyLinkedList { constructor(head) { this.head = head; } add() {} remove() {} *[Symbol.iterator]() { let node = this.head; while (node !== null) { yield node.data; node = node.next; } }}let tail = { data: { code: 'de', name: 'Germany' }, next: null,};let node = { data: { code: 'pl', name: 'Poland' }, next: tail,};let head = { data: { code: 'at', name: 'Austria' }, next: node,};const linkedList = new SinglyLinkedList(head);for (const data of linkedList) { console.log(data);}Array.from(linkedList); // converts our list to Array
Note on transpilers
In general, transpilers such as Babel tend to have 2 modes — loose (more sloppy,
less spec-compliant) and a more strict one that follows the spec. In order to
see how the outputs differ, we can go to the official
REPL. Let's transpile the following example using
React's useState
hook.
const [a, b] = useState();
Non-loose mode
'use strict';var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for ( var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true ) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError( 'Invalid attempt to destructure non-iterable instance', ); } };})();var _useState = useState(), _useState2 = _slicedToArray(_useState, 2), a = _useState2[0], b = _useState2[1];
Loose mode
'use strict';var _useState = useState(), a = _useState[0], b = _useState[1];
As we can see, the first transpiled piece of code does follow the spec more closely and performs the iteration. The latter clearly does not. Is it a safe assumption to make? Well, it is unless you modify the iterator method on the native object.
Note on JS engines
A few great resources:
I really recommend reading these.