Downlevel Iteration for ES3/ES5 in TypeScript
TypeScript 2.3 introduced a new --downlevelIteration
flag that adds full support for the ES2015 iteration protocol for ES3 and ES5 targets. for...of
-loops can now be downlevel-compiled with correct semantics.
#Iterating over Arrays Using for...of
Let's assume this brief tsconfig.json
file for the following TypeScript code examples. The only option we configure in the beginning is our ECMAScript language target — in this case, ES5:
{
"compilerOptions": {
"target": "es5"
}
}
Check out the following index.ts
file. Nothing fancy, just an array of numbers and an ES2015 for...of
-loop that iterates over the array and outputs every number:
const numbers = [4, 8, 15, 16, 23, 42];
for (const number of numbers) {
console.log(number);
}
We can execute the index.ts
file directly without running it through the TypeScript compiler first because it doesn't contain any TypeScript-specific syntax:
$ node index.ts
4
8
15
16
23
42
Let's now compile the index.ts
file into index.js
:
$ tsc -p .
Looking at the emitted JavaScript code, we can see that the TypeScript compiler generated a traditional index-based for
-loop to iterate over the array:
var numbers = [4, 8, 15, 16, 23, 42];
for (var _i = 0, numbers_1 = numbers; _i < numbers_1.length; _i++) {
var number = numbers_1[_i];
console.log(number);
}
If we run this code, we can quickly see that it works as intended:
$ node index.js
4
8
15
16
23
42
The observable output of running node index.ts
and node.index.js
is identical, just as it should be. This means we haven't changed the behavior of the program by running it through the TypeScript compiler. Good!
#Iterating over Strings Using for...of
Here's another for...of
-loop. This time, we're iterating over a string rather than an array:
const text = "Booh! 👻";
for (const char of text) {
console.log(char);
}
Again, we can run node index.ts
directly because our code only uses ES2015 syntax and nothing specific to TypeScript. Here's the output:
$ node index.ts
B
o
o
h
!
👻
Now it's time to compile index.ts
to index.js
again. When targeting ES3 or ES5, the TypeScript compiler will happily generate an index-based for
-loop for the above code:
var text = "Booh! 👻";
for (var _i = 0, text_1 = text; _i < text_1.length; _i++) {
var char = text_1[_i];
console.log(char);
}
Unfortunately, the emitted JavaScript code behaves observably differently from the original TypeScript version:
$ node index.js
B
o
o
h
!
�
�
The ghost emoji — or the code point U+1F47B
, to be more precise — consists of the two code units U+D83D
and U+DC7B
. Because indexing into a string returns the code unit (rather than the code point) at that index, the emitted for
-loop breaks up the ghost emoji into its individual code units.
On the other hand, the string iteration protocol iterates over each code point of the string. This is why the output of the two programs differs. You can convince yourself of the difference by comparing the length
property of the string and the length of the sequence produced by the string iterator:
const ghostEmoji = "\u{1F47B}";
console.log(ghostEmoji.length); // 2
console.log([...ghostEmoji].length); // 1
Long story short: iterating over strings using a for...of
-loop doesn't always work correctly when targeting ES3 or ES5. This is where the new --downlevelIteration
flag introduced with TypeScript 2.3 comes into play.
#The --downlevelIteration
Flag
Here's our index.ts
from before again:
const text = "Booh! 👻";
for (const char of text) {
console.log(char);
}
Let's now modify our tsconfig.json
file and set the new downlevelIteration
compiler option to true
:
{
"compilerOptions": {
"target": "es5",
"downlevelIteration": true
}
}
If we run the compiler again, the following JavaScript code is emitted:
var __values = (this && this.__values) || function (o) {
var m = typeof Symbol === "function" && o[Symbol.iterator], i = 0;
if (m) return m.call(o);
return {
next: function () {
if (o && i >= o.length) o = void 0;
return { value: o && o[i++], done: !o };
}
};
};
var text = "Booh! 👻";
try {
for (var text_1 = __values(text), text_1_1 = text_1.next(); !text_1_1.done; text_1_1 = text_1.next()) {
var char = text_1_1.value;
console.log(char);
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (text_1_1 && !text_1_1.done && (_a = text_1.return)) _a.call(text_1);
}
finally { if (e_1) throw e_1.error; }
}
var e_1, _a;
As you can see, the generated code is a lot more elaborate than a simple for
-loop. This is because it contains a proper implementation of the iteration protocol:
- The
__values
helper function looks for a[Symbol.iterator]
method and calls it if it was found. If not, it creates a synthetic array iterator over the object instead. - Instead of iterating over each code unit, the
for
-loop calls the iterator'snext()
method until it is exhausted, in which casedone
istrue
. - To implement the iteration protocol according to the ECMAScript specification,
try
/catch
/finally
blocks are generated for proper error handling.
If we now execute the index.js
file again, we get the correct output:
$ node index.js
B
o
o
h
!
👻
Note that you still need a shim for Symbol.iterator
if your code is executed in an environment that doesn't natively define this symbol, e.g. an ES5 environment. If Symbol.iterator
is not defined, the __values
helper function will be forced to create a synthetic array iterator that doesn't follow the proper iteration protocol.
#Using Downlevel Iteration with ES2015 Collections
ES2015 added new collection types such as Map
and Set
to the standard library. In this section, I want to look at how to iterate over a Map
using a for...of
-loop.
In the following example, I create a mapping from numeric digits to their respective English names. I initialize a Map
with ten key-value pairs (represented as two-element arrays) in the constructor. Afterwards, I use a for...of
-loop and an array destructuring pattern to decompose the key-value pairs into digit
and name
:
const digits = new Map([
[0, "zero"],
[1, "one"],
[2, "two"],
[3, "three"],
[4, "four"],
[5, "five"],
[6, "six"],
[7, "seven"],
[8, "eight"],
[9, "nine"]
]);
for (const [digit, name] of digits) {
console.log(`${digit} -> ${name}`);
}
This is perfectly valid ES2015 code which runs as expected:
$ node index.ts
0 -> zero
1 -> one
2 -> two
3 -> three
4 -> four
5 -> five
6 -> six
7 -> seven
8 -> eight
9 -> nine
However, the TypeScript compiler is unhappy, saying that it cannot find Map
:
This is because we're targeting ES5, which doesn't implement the Map
collection. How would we make this code compile, assuming we have provided a polyfill for Map
so that the program works at run-time?
The solution is to add the "es2015.collection"
and "es2015.iterable"
values to the lib
option within our tsconfig.json
file. This tells the TypeScript compiler that it can assume to find ES2015 collection implementations and the Symbol.iterator
symbol at run-time. Once you explicitly specify the lib
option, however, its defaults no longer apply. Therefore, you should add "dom"
and "es5"
in there as well so that you can access other standard library methods.
Here's the resulting tsconfig.json
:
{
"compilerOptions": {
"target": "es5",
"downlevelIteration": true,
"lib": [
"dom",
"es5",
"es2015.collection",
"es2015.iterable"
]
}
}
Now, the TypeScript compiler no longer complains and emits the following JavaScript code:
var __values = (this && this.__values) || function (o) {
var m = typeof Symbol === "function" && o[Symbol.iterator], i = 0;
if (m) return m.call(o);
return {
next: function () {
if (o && i >= o.length) o = void 0;
return { value: o && o[i++], done: !o };
}
};
};
var __read = (this && this.__read) || function (o, n) {
var m = typeof Symbol === "function" && o[Symbol.iterator];
if (!m) return o;
var i = m.call(o), r, ar = [], e;
try {
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
}
catch (error) { e = { error: error }; }
finally {
try {
if (r && !r.done && (m = i["return"])) m.call(i);
}
finally { if (e) throw e.error; }
}
return ar;
};
var digits = new Map([
[0, "zero"],
[1, "one"],
[2, "two"],
[3, "three"],
[4, "four"],
[5, "five"],
[6, "six"],
[7, "seven"],
[8, "eight"],
[9, "nine"]
]);
try {
for (var digits_1 = __values(digits), digits_1_1 = digits_1.next(); !digits_1_1.done; digits_1_1 = digits_1.next()) {
var _a = __read(digits_1_1.value, 2), digit = _a[0], name_1 = _a[1];
console.log(digit + " -> " + name_1);
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (digits_1_1 && !digits_1_1.done && (_b = digits_1.return)) _b.call(digits_1);
}
finally { if (e_1) throw e_1.error; }
}
var e_1, _b;
Try it out for yourself — this code prints the correct output.
There's one more thing we should take care of, though. The generated JavaScript code now includes two helper functions, __values
and __read
, which significantly blow up the code size. Let's try to bring that down.
#Reducing Code Size with --importHelpers
and tslib
In the code example above, the __values
and __read
helper functions were inlined into the resulting JavaScript code. This is unfortunate if you're compiling a TypeScript project with multiple files. Every emitted JavaScript file will contain all helpers necessary to execute that file, resulting in much bigger code!
In a typical project setup, you'll use a bundler such as webpack to bundle together all your modules. The bundle that webpack generates will be unnecessarily big if it contains a helper function more than once.
The solution is to use the --importHelpers
compiler option and the tslib
npm package. When specified, --importHelpers
will cause the TypeScript compiler to import all helpers from tslib
. Bundlers like webpack can then inline that npm package only once, avoiding code duplication.
To demonstrate the effect of --importHelpers
, I'll first turn our index.ts
file into a module by exporting a function from it:
const digits = new Map([
[0, "zero"],
[1, "one"],
[2, "two"],
[3, "three"],
[4, "four"],
[5, "five"],
[6, "six"],
[7, "seven"],
[8, "eight"],
[9, "nine"]
]);
export function printDigits() {
for (const [digit, name] of digits) {
console.log(`${digit} -> ${name}`);
}
}
Now we need to modify our compiler configuration and set importHelpers
to true
. Here's our final tsconfig.json
file:
{
"compilerOptions": {
"target": "es5",
"downlevelIteration": true,
"importHelpers": true,
"lib": [
"dom",
"es5",
"es2015.collection",
"es2015.iterable"
]
}
}
This is what the resulting JavaScript code looks like after running it through the compiler:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var tslib_1 = require("tslib");
var digits = new Map([
[0, "zero"],
[1, "one"],
[2, "two"],
[3, "three"],
[4, "four"],
[5, "five"],
[6, "six"],
[7, "seven"],
[8, "eight"],
[9, "nine"]
]);
function printDigits() {
try {
for (var digits_1 = tslib_1.__values(digits), digits_1_1 = digits_1.next(); !digits_1_1.done; digits_1_1 = digits_1.next()) {
var _a = tslib_1.__read(digits_1_1.value, 2), digit = _a[0], name_1 = _a[1];
console.log(digit + " -> " + name_1);
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (digits_1_1 && !digits_1_1.done && (_b = digits_1.return)) _b.call(digits_1);
}
finally { if (e_1) throw e_1.error; }
}
var e_1, _b;
}
exports.printDigits = printDigits;
Notice that the code no longer contains inlined helper functions. Instead, the tslib
package is required at the beginning.
And there you go! Spec-compliant, downlevel-compiled for...of
-loops, full support for the iteration protocol, and no redundant TypeScript helpers.
This article and 44 others are part of the TypeScript Evolution series. Have a look!