Short answer
You get different iterators for arr.values() and arr.values().map(). They react differently to destructuring assignments.
Any iterable can optionally declare a return() and throw() methods. These should close the iterable when called. Some of the syntactic sugar around iterators will attempt to automatically call these if available.
The iterator returned from arr.values() does not expose a .return() method.
The iterator returned from the iteration helper .map() does expose a .return() method.
const arr = [1, 2, 3];
console.log(arr.values().return);
console.log(arr.values().map(x => x).return);
A solution
Use a wrapper that will only proxy next() calls for but would not allow closing an iterator
function uncloseable(it) {
return {
next() {
//forward all calls and arguments to the wrapped iterable
return it.next.apply(it, arguments);
},
//make it a valid iterable by returning itself
[Symbol.iterator]() {
return this;
}
};
}
let m = n => n * 2;
let it2 = uncloseable([1, 2, 3].values ().map (m));
let [a2] = it2,
[...b2] = it2;
console.log (a2 + '', b2 + ''); // '2'
A similar thing can be achieved with a proxy. This might be useful if only .return()/.throw() should be blocked but everything else goes through:
Long explanation
We need to do a deep dive into the iteration protocol to get to the reason for this behaviour.
There are three methods that an iterator can implement according to the standard:
.next() is mandatory. It will return the current position of the iterator and advance it one step further.
.return() is optional. Calling it should close the iterator.
.throw() is optional. Calling it should indicate an error was encountered and the spec suggests the iterator is closed with this. But does not mandate it.
Quick note on terminology - the above is true for iterators. There are also iterables. For an object to be an iterable, it has to have a @@Symbol.iterator method which will return an iterator. Finally, these are not mutually exclusive - there are iterable iterators which are both.
Language features like for..of or destructuring have specific semantics assigned to them where they will implicitly try to close the iterator.
Specifically, here the array destructuring assignment specification if we examine an example like [x1, x2] = xs the following will happen:
- The assignment value (
xs) is treated as an iterable and iterator for it is obtained.
- When the assignment value is an iterable iterator, it will typically returns itself. That is what the iterator obtained from
Array#values does. But if xs was an array (an iterable but not an iterator), then it would essentially get a brand new iterator which .values() provides.
- Values are drawn from the iterator to match the right-hand assignment elements (in the example, that is
x1 and x2).
- Once all the assignment elements are satisfied and the iterator is still open (it returned
done: false for the last assignment) then attempt to close it.
- Closing the iterator is done by calling
.return(). If no .return() is available, then the attempt is just a noop.
In effect, processing the example array destructuring assignment looks similar to this (simplified for illustrative purpose):
//obtain iterator
const iterator = xs[Symbol.iterator]();
let lastResult;
//assign to the first assignment element
lastResult = iterator.next();
const x1 = lastResult.value;
//assign to the second assignment element
lastResult = iterator.next();
const x2 = lastResult.value;
//assignments finished
//attempt to close the iterator
if(lastResult.done !== false && typeof iterator.return === "function") {
iterator.return();
}
We can observe this easily by creating a custom iterator.
This will not close:
/* simple iterator that will return 1, 2, 3 */
const uncloseableIterator = {
current: 1,
done: false,
next() {
if (this.done || this.current > 3) {
this.done = true;
return { done: true };
} else {
return { value: this.current++, done: false };
}
},
//make it a valid iterable by returning itself
[Symbol.iterator]() { return this; }
}
const [a] = uncloseableIterator;
const [b] = uncloseableIterator;
console.log("values:", a, b);
This will be closed:
/* simple iterator that will return 1, 2, 3 */
const closeableIterator = {
current: 1,
done: false,
next() {
if (this.done || this.current > 3) {
this.done = true;
return { done: true };
} else {
return { value: this.current++, done: false };
}
},
//expose a return() method
return() {
console.log("ITERATOR CLOSED");
this.done = true;
return { done: true }
},
//make it a valid iterable by returning itself
[Symbol.iterator]() { return this; }
}
console.log("assignment to `a`");
const [a] = closeableIterator;
console.log("assignment to `b`");
const [b] = closeableIterator;
console.log("values:", a, b);
So, it is the destructuring assignment that will always try to close the iterator after using it. And Iterator helper objects (returned from Iterator#map and other iterator helper methods) expose a .next() and .return() methods and are thus closeable.
for..of
As a special note - for..of will also exhibit a similar behaviour if terminated early:
- encountering a
break statement will attempt to call the .return() method of the iterable
/* simple iterator that will return 1, 2, 3 */
const closeableIterator = {
current: 1,
done: false,
next() {
if (this.done || this.current > 3) {
this.done = true;
return { done: true };
} else {
return { value: this.current++, done: false };
}
},
//expose a return() method
return() {
console.log("ITERATOR CLOSED WITH .return()");
this.done = true;
return { done: true }
},
//make it a valid iterable by returning itself
[Symbol.iterator]() { return this; }
}
console.log("looping using for..of");
for (const item of closeableIterator) {
console.log("item:", item);
break; //finish early
}
console.log("end for..of loop");
- encountering a
throw statement will attempt to call the .throw() method of the iterable
/* simple iterator that will return 1, 2, 3 */
const closeableIterator = {
current: 1,
done: false,
next() {
if (this.done || this.current > 3) {
this.done = true;
return { done: true };
} else {
return { value: this.current++, done: false };
}
},
//expose a return() method
return() {
console.log("ITERATOR CLOSED WITH .return()");
this.done = true;
return { done: true }
},
//expose a throw() method
return() {
console.log("ITERATOR CLOSED WITH .throw()");
this.done = true;
return { done: true }
},
//make it a valid iterable by returning itself
[Symbol.iterator]() { return this; }
}
console.log("looping using for..of");
for (const item of closeableIterator) {
console.log("item:", item);
throw new Error("terminate early due to an error");
}
console.log("end for..of loop");