skip to content

With `"target": "es5"` in tsconfig, TypeScript accepts `for (const x of myArray)` but rejects `for (const [k, v] of myMap)` and points you at the `downlevelIteration` option. Why the difference, and what does enabling that flag change in the emitted code?

level: middleimportance: should knowfreq 38%

answer

  1. default emit is an index loop
  2. array-likes only
  3. protocol needs a well-known symbol
  4. helpers call next() in try/finally
  5. inert above ES2015 target

basics

~20 s

At an ES5 target TypeScript compiles for...of over arrays and strings into a plain index loop, which cannot work for a Map. downlevelIteration makes the compiler emit helpers that call Symbol.iterator instead, so any iterable works — but the runtime must actually provide Symbol.iterator.

solid answer

~50 s

Without `downlevelIteration`, an ES5 target compiles `for...of` into a simple `for (var i = 0; i < arr.length; i++)` index loop. That is fast and needs no runtime support, but it only works for arrays and strings — so the compiler refuses any other iterable, such as a `Map`, `Set`, or generator, and tells you to enable the flag. With `downlevelIteration` on, the compiler emits helper functions instead (`__values`, `__read`, `__spreadArray`) that obtain an iterator via `Symbol.iterator` and pump `next()`, which is the faithful ES2015 protocol. The cost is twofold: bigger, slower output even for plain arrays, and a hard runtime requirement that `Symbol.iterator` exists and that the built-ins implement it — so an ES5-era engine still needs a polyfill. The flag has no effect at all when `target` is ES2015 or higher, because nothing is downleveled.

code

typescript · 6 lines
typescript
const seen = new Map<string, number>([["a", 1]]);
for (const [key, count] of seen) {
  console.log(key, count);
}
const keys = [...seen.keys()];
console.log(keys);

go deeper

for a junior

Know that at an ES5 target for...of is compiled into an index loop, which works for arrays and strings but not for a Map or Set.

for a middle

Explain the iteration protocol the helpers restore, name the constructs affected beyond for...of, and state that the flag is inert once target is ES2015 or higher.

for a senior

Weigh the tradeoff out loud — larger, slower emit on every downleveled loop — and insist on the matching runtime polyfill before calling the fix complete.

for a principal

Frame it as a baseline decision: whether shipping ES5 at all is still justified, given that raising the target removes this flag, its helpers and its polyfill burden entirely.

## What `for...of` means, and why ES5 cannot express it In ES2015 and later, `for...of` is defined over the **iteration protocol**: the loop asks the object for `obj[Symbol.iterator]()`, gets back an iterator, and calls `next()` until `done` is true. Arrays, strings, `Map`, `Set`, generators, `NodeList` and many other things implement that protocol. ES5 has no `Symbol`, so it cannot express that protocol natively. When you set `"target": "es5"`, the compiler must rewrite `for...of` into something ES5 can run — and it has two possible strategies. ## Strategy one: the index loop (the default) By default, `tsc` compiles `for (const x of arr)` into an index-based loop: ```javascript for (var i = 0; i < arr.length; i++) { var x = arr[i]; } ``` This is compact, needs no helper, and runs on any ES3/ES5 engine. But it is only *correct* for array-likes: things with a numeric `length` and integer indices — arrays and strings. A `Map` has neither, so the loop would silently do nothing. Rather than emit wrong code, the compiler raises an error telling you the type is not an array or string type and that `downlevelIteration` would allow iterating iterators. There is a subtle correctness caveat even for strings: the index loop walks UTF-16 code units, so an astral-plane character (an emoji, for instance) is split into surrogate halves, whereas real `for...of` yields whole code points. That is a second, quieter reason to enable the flag if you handle such text. ## Strategy two: the protocol helpers (`downlevelIteration`) Turn the flag on and the compiler stops guessing. It emits a `__values` helper that looks up `Symbol.iterator` on the object and drives `next()` in a `try`/`finally` (so an early `break` still calls `return()` on the iterator, matching real `for...of` semantics). The same machinery powers two other constructs at low targets: - **array spread** — `[...someSet]` and `f(...args)` compile through `__read`/`__spreadArray` instead of `slice`-based tricks; - **destructuring from an iterable** — `const [a, b] = someIterable`. The standard helper does keep an array-like fallback: if `Symbol.iterator` is missing but the object has a numeric `length`, it walks it by index. That softens the failure for plain arrays, but it does nothing for a `Map` on an engine with no real `Symbol`. ## The runtime requirement people forget `downlevelIteration` changes emit; it does not add capability. The emitted helper calls `Symbol.iterator`, so on a genuinely old engine you need a `Symbol` polyfill **and** built-ins that expose `Symbol.iterator` — that is, a real polyfill package such as `core-js`, not just a `Symbol` shim. This is the same lesson as `lib`: the compiler rewrites syntax, and runtime capability is your responsibility. On the type side, iterating a `Map` also requires the corresponding declarations to be loaded, which come from the ES2015 (or later) libs. A build with `target: es5` and no explicit `lib` will not even know `Map` exists. ## Cost and when to enable it The helpers are slower and larger than an index loop, and they apply to *every* downleveled `for...of` in the program, including the hot ones over plain arrays. In a performance-sensitive ES5 bundle that is a real, measurable cost — which is precisely why the compiler does not enable the faithful behaviour by default. Practical guidance: - **Target ES2015 or higher if you possibly can.** Then `for...of` is emitted as written, the flag becomes a no-op, and the whole question evaporates. This is the right answer for most projects today. - **If you must ship ES5**, enable `downlevelIteration` as soon as you iterate anything other than arrays and strings, and ship the matching polyfill. - **If you enable it purely for correctness on strings**, be explicit about that in a comment, because the size regression will otherwise look unmotivated in review. ## What interviewers listen for The candidate should name the index-loop default, connect the error to the loss of the iteration protocol, and — most importantly — say that the flag needs runtime `Symbol.iterator` support rather than providing it. Adding that it is inert above an ES2015 target shows they understand *why* the flag exists rather than just what it toggles.

  • Besides for...of, what other syntax does downlevelIteration affect?
    Array spread and iterable destructuring. At a low target, `[...someSet]`, `f(...args)` and `const [a, b] = iterable` are compiled through the `__read`/`__spreadArray` helpers when the flag is on, so they honour `Symbol.iterator` instead of assuming an array-like. Without the flag they are restricted to arrays and strings just as `for...of` is.
  • Does enabling downlevelIteration make Map iteration work on an engine with no Symbol?
    No. The flag only changes what the compiler emits; the emitted helper still looks up `Symbol.iterator` at runtime. You need a real polyfill that installs `Symbol` and makes the built-ins iterable — the compiler never provides that.
  • What does the flag do when target is es2022?
    Nothing. `for...of`, spread and iterable destructuring are emitted as written at any target from ES2015 up, so there is no downleveling for the flag to influence. It matters only for ES5 and ES3-era output.
  • Why is the index-loop default considered a reasonable tradeoff rather than a bug?
    Because array iteration dominates real code and the index loop is markedly smaller and faster than driving an iterator through helpers. The compiler errors rather than silently miscompiling anything the fast path cannot handle, so you opt into the cost only where you actually need the protocol.

saying these in an interview costs you the question

  • Thinks downlevelIteration polyfills Symbol.iterator itself
  • Believes the flag matters at modern targets too
  • Says for...of over a Map silently loops zero times by default
  • Assumes the flag only affects for...of, not spread
  • Treats the index-loop default as a compiler bug

context