TypeScript Map and Set Iteration in 2026: Why downlevelIteration Still Breaks Production Builds

This article was written with the assistance of AI, under human supervision and review.

TypeScript's downlevelIteration flag determines how the compiler transpiles Map, Set, and array iteration when targeting older JavaScript runtimes. Without it, production builds silently produce broken code that crashes on iterables at runtime. The default behavior downlevels for...of loops to index-based iteration, which fails on objects that implement Symbol.iterator but lack numeric indices.

The flag's confusing name compounds the problem. Developers expect "downlevel" to mean "produce simpler code for older targets," but enabling the flag actually generates more complex helper code that checks for Symbol.iterator before falling back to index-based iteration. The flag appears optional until a production build crashes with "Object is not iterable" on a perfectly valid Map.values() call.

Problem flowchart showing downlevelIteration false leading to index-based iteration that crashes on Map

Problem flowchart showing downlevelIteration false leading to index-based iteration that crashes on Map

The correct configuration enables downlevelIteration when targeting runtimes below ES2015 and using Map, Set, or any iterable protocol. The transpiled output includes a __values helper that tests for Symbol.iterator support before attempting iteration. This prevents the crash but increases bundle size by 400-800 bytes per file that uses iteration.

Solution flowchart showing downlevelIteration true generating helper code that handles iterables correctly

Solution flowchart showing downlevelIteration true generating helper code that handles iterables correctly

Key Takeaways

  • The downlevelIteration flag controls whether TypeScript emits index-based loops or iterator-protocol-aware helpers when transpiling iteration to ES5 or earlier.
  • Without downlevelIteration, for...of loops on Map, Set, or any Symbol.iterator object crash at runtime because the default transpilation assumes numeric indices.
  • TypeScript 6.0 deprecated target: es5, but builds targeting ES2015+ still encounter iteration failures in polyfilled environments where iterables exist but the target is set to ES5 for legacy browser support.
  • Enabling downlevelIteration adds 400-800 bytes per file but prevents runtime crashes when iterating Map, Set, array spreads, or destructuring iterables.
  • The flag name misleads developers into thinking it produces simpler code for older targets, when it actually generates more sophisticated helpers that respect the iteration protocol.

What downlevelIteration Actually Does (And Why the Name Confuses Everyone)

TypeScript's terminology inverts the intuitive meaning of "downlevel." The flag name suggests producing simpler code, but downlevelIteration: true emits more complex helpers that implement ES2015 iteration semantics in ES5 environments.

When targeting ES5 or earlier, TypeScript must transpile iteration primitives introduced in ES2015: for...of loops, array spreads ([...arr]), argument spreads (fn(...args)), and destructuring iterables. The default behavior produces index-based loops that fail on objects implementing Symbol.iterator.

const map = new Map([['a', 1], ['b', 2]]); for (const [key, value] of map) { console.log(key, value); } 

Without downlevelIteration, this transpiles to:

var map = new Map([['a', 1], ['b', 2]]); for (var _i = 0, map_1 = map; _i < map_1.length; _i++) { var _a = map_1[_i], key = _a[0], value = _a[1]; console.log(key, value); } 

The transpiled code accesses map.length and map[0], which do not exist on Map objects. The loop never executes. Runtime engines that support Map but run ES5 code produce no error and no output, silently skipping the iteration. Older engines that lack Map entirely throw "Map is not defined," but that failure occurs earlier during Map construction, masking the iteration problem.

TypeScript downlevelIteration flag configuration example

TypeScript downlevelIteration flag configuration example

With downlevelIteration: true, TypeScript emits a __values helper that checks for Symbol.iterator before falling back to index-based iteration:

var __values = (this && this.__values) || function(o) { var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; if (m) return m.call(o); if (o && typeof o.length === "number") return { next: function () { if (o && i >= o.length) o = void 0; return { value: o && o[i++], done: !o }; } }; throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); }; var map = new Map([['a', 1], ['b', 2]]); try { for (var map_1 = __values(map), map_1_1 = map_1.next(); !map_1_1.done; map_1_1 = map_1.next()) { var _a = map_1_1.value, key = _a[0], value = _a[1]; console.log(key, value); } } catch (e_1_1) { e_1 = { error: e_1_1 }; } finally { try { if (map_1_1 && !map_1_1.done && (_a = map_1.return)) _a.call(map_1); } finally { if (e_1) throw e_1.error; } } 

The helper first tests whether Symbol.iterator exists on the object. If present, it calls the iterator method and consumes the result. If absent, it falls back to numeric indexing. The transpiled loop now respects the iteration protocol, correctly iterating Map entries.

The confusion arises because "downlevel" typically means "produce simpler code for older targets." Enabling downlevelIteration does the opposite: it produces more sophisticated code that handles both iterable and array-like objects. The flag should have been named strictIteration or iteratorHelpers, but the existing name persists in TypeScript 6.0 despite the ES5 target deprecation.

Concept diagram showing how downlevelIteration transforms iteration to check Symbol.iterator before falling back to index-based loops

Concept diagram showing how downlevelIteration transforms iteration to check Symbol.iterator before falling back to index-based loops

Map and Set Iteration Without the Flag: The Silent Breakage

The runtime failure mode depends on the JavaScript engine and the transpilation target. Modern engines that support Map and Set execute the broken code without throwing errors. The iteration simply skips all entries, producing silent bugs that pass unit tests running on modern runtimes but fail in production on older browsers.

A production application targeting ES5 for IE11 compatibility experiences the failure when deployed:

const permissions = new Set(['read', 'write', 'delete']); const granted = [...permissions]; console.log(granted); 

Without downlevelIteration, the spread operator transpiles to:

var permissions = new Set(['read', 'write', 'delete']); var granted = [].concat(permissions); console.log(granted); 

The Array.prototype.concat method expects array-like objects or primitives. When passed a Set, it treats the entire Set as a single element. The output is [Set(3)], not ['read', 'write', 'delete']. The code executes without errors, but the result is wrong.

Unit tests running on Node.js 20 or Chrome 120 pass because those environments execute the original TypeScript source through transpilation pipelines that default to ES2020+ targets. The test suite never exercises the ES5 transpilation. The bug only appears when the production build runs on the intended target runtime.

Map and Set iteration failure visualization

Map and Set iteration failure visualization

The for...of loop failure is harder to detect because the loop body never executes. Developers writing iteration logic for Map or Set assume the loop will run at least once if the collection is non-empty. When the transpiled code silently skips all entries, downstream logic that depends on the iteration produces unexpected results without obvious stack traces pointing to the iteration site.

A real-world example from an e-commerce checkout flow:

const lineItems = new Map<string, number>(); lineItems.set('item-123', 2); lineItems.set('item-456', 1); let total = 0; for (const [id, quantity] of lineItems) { const price = getPriceById(id); total += price * quantity; } if (total === 0) { throw new Error('Cart is empty'); } 

Without downlevelIteration, the for...of loop never executes. The total remains 0, and the code throws "Cart is empty" even though the lineItems map contains entries. The error message misleads the developer into investigating cart state management when the actual bug is transpilation configuration.

The destructuring assignment inside the loop exacerbates the failure. The transpiled code attempts to destructure map_1[_i], which is undefined. Older engines throw "Cannot read property '0' of undefined," providing a clearer diagnostic. Modern engines that support destructuring but execute the ES5 transpilation produce the same error, but the stack trace points to the transpiled output, not the original source, complicating debugging.

For a detailed comparison of how TypeScript handles readonly collections, see the ReadonlyMap and ReadonlySet in TypeScript post.

TypeScript 6.0 Changes: Why This Flag Still Matters After ES5 Deprecation

TypeScript 6.0 deprecated target: es5, making ES2015 the minimum supported target. The deprecation does not eliminate the need for downlevelIteration because production builds still target ES5 through external tooling or continue using the deprecated target with a suppression flag.

Configuration scenario downlevelIteration requirement Reason
TypeScript 6.0 with target: es2015 or higher Not required Native for...of and Symbol.iterator available in target runtime
TypeScript 6.0 with target: es5 (deprecated) Required if using Map, Set, or iterables Transpilation still produces index-based loops without the flag
TypeScript 5.x with target: es5 Required if using Map, Set, or iterables No deprecation; standard configuration
External compiler (Babel, SWC) with ES5 target Depends on external tool TypeScript does not control final output; Babel and SWC handle iteration independently

The deprecation warning reads:

Option 'target: es5' is deprecated and will stop functioning in a future release. Use 'target: es2015' or higher. 

Projects that suppress the warning by adding "ignoreDeprecations": "5.0" to tsconfig.json continue using target: es5 in TypeScript 6.0. The transpilation behavior remains identical to TypeScript 5.x. The downlevelIteration flag still determines whether iteration produces index-based loops or iterator-aware helpers.

External compilers introduce another dimension. A common build pipeline uses TypeScript for type checking only ("noEmit": true) and Babel or SWC for transpilation. These tools handle iteration independently:

Comparison flowchart showing TypeScript 6.0 with target es5 vs. target es2015 handling of downlevelIteration

Comparison flowchart showing TypeScript 6.0 with target es5 vs. target es2015 handling of downlevelIteration

Babel's @babel/preset-env transpiles iteration based on the targets configuration, not TypeScript's target. When targets includes ES5 browsers, Babel emits its own iteration helpers regardless of downlevelIteration. SWC behaves similarly, using its jsc.target option to determine transpilation.

A mixed configuration causes confusion:

{ "compilerOptions": { "target": "es2015", "downlevelIteration": false } } 

TypeScript emits ES2015 code with native for...of loops. Babel then transpiles to ES5, producing index-based loops that fail on iterables. The developer expects downlevelIteration: false to be safe because the TypeScript target is ES2015, but the final output still breaks.

The solution requires aligning TypeScript's target with the final output target. When using external transpilers, set TypeScript's target to match the highest version the pipeline supports before external transpilation. For type-checking-only workflows, set target: esnext and let the external tool handle all transpilation.

The TypeScript team's recommendation in the 6.0 release notes suggests using external compilers for ES5 output rather than fighting the deprecated target: es5 option. This shifts the iteration problem to Babel or SWC configuration but does not eliminate it. Developers still need to understand how iteration transpiles to ES5 to configure the external tool correctly.

Real-World Build Failures: Spread Operators, for...of, and Destructuring

Production failures cluster around three iteration primitives: spread operators, for...of loops, and destructuring assignments. Each fails differently when downlevelIteration is disabled and the target is ES5.

Array spreads produce wrong results without throwing errors:

const base = [1, 2, 3]; const extended = [0, ...base, 4]; 

Without downlevelIteration, this transpiles to:

var base = [1, 2, 3]; var extended = [0].concat(base, [4]); 

For arrays, concat works correctly. The problem appears when spreading non-array iterables:

const uniqueIds = new Set(['a', 'b', 'c']); const idArray = [...uniqueIds]; 

The transpiled code uses concat:

var uniqueIds = new Set(['a', 'b', 'c']); var idArray = [].concat(uniqueIds); 

The result is [Set(3)], not ['a', 'b', 'c']. The spread operator treats the entire Set as one element.

Destructuring assignments fail silently on Map.entries():

const config = new Map([['timeout', 5000], ['retries', 3]]); const [[timeoutKey, timeoutValue]] = config; 

The transpiled code accesses config[0]:

var config = new Map([['timeout', 5000], ['retries', 3]]); var _a = config[0], timeoutKey = _a[0], timeoutValue = _a[1]; 

The expression config[0] is undefined. Destructuring undefined throws "Cannot read property '0' of undefined." The error message does not mention Map or iteration, obscuring the root cause.

Practical flowchart showing three iteration primitives and their failure modes without downlevelIteration

Practical flowchart showing three iteration primitives and their failure modes without downlevelIteration

A production case from a logging pipeline:

const logLevels = new Set(['error', 'warn', 'info']); const filters = [...logLevels].map(level => `level:${level}`); 

Without downlevelIteration, filters becomes ["[object Set]"] instead of ["level:error", "level:warn", "level:info"]. The logging query breaks, filtering out all logs because the filter string does not match any actual log level.

The for...of failure affects React applications that iterate Map state:

const [items, setItems] = useState(new Map()); return ( <ul> {Array.from(items).map(([id, item]) => ( <li key={id}>{item.name}</li>  ))} </ul> ); 

When transpiled to ES5 without downlevelIteration, the Array.from call fails because items[Symbol.iterator] does not exist in the transpiled output's mental model. The component renders an empty list even when items contains entries.

The Biome linter detects some iteration patterns that require downlevelIteration, but the check is not comprehensive. For more on Biome's capabilities, see the Biome vs. oxlint Comparison in 2026 post.

Migration Strategy: When to Enable It and What Breaks When You Don't

Enabling downlevelIteration requires evaluating the tradeoff between bundle size and correctness. The flag adds 400-800 bytes per file that uses iteration, depending on how many iteration primitives appear in the source.

A migration strategy starts with identifying files that iterate Map, Set, or non-array iterables. Grep the codebase for for...of, spread operators, and destructuring:

grep -rn "for (const .* of" src/ grep -rn "\.\.\." src/ grep -rn "const \[.*\] =" src/ 

Files that iterate only arrays do not require downlevelIteration. The default index-based transpilation works correctly for arrays because they have numeric indices and a length property.

Migration flowchart showing decision tree for when to enable downlevelIteration

Migration flowchart showing decision tree for when to enable downlevelIteration

For projects using target: es5 in TypeScript 6.0, enable downlevelIteration immediately. The bundle size increase is unavoidable when correctness depends on iteration over non-array iterables.

For projects using external transpilers, the configuration depends on the tool:

Babel with @babel/preset-env:

{ "presets": [ ["@babel/preset-env", { "targets": { "ie": "11" }, "useBuiltIns": "usage", "corejs": 3 }] ] } 

Babel's useBuiltIns: usage option automatically includes the iteration helpers when the source code uses iteration primitives. The TypeScript downlevelIteration setting has no effect because Babel performs the transpilation.

SWC with jsc.target: es5:

{ "jsc": { "target": "es5", "loose": false } } 

SWC's loose: false setting emits strict iteration helpers that respect Symbol.iterator. Setting loose: true produces simpler but incorrect transpilation that assumes all iterables are arrays.

A hybrid approach splits the codebase. Modules that use Map or Set enable downlevelIteration via a separate tsconfig.json:

{ "extends": "./tsconfig.base.json", "compilerOptions": { "downlevelIteration": true }, "include": ["src/collections/**/*"] } 

The base configuration keeps downlevelIteration: false, reducing bundle size for modules that iterate only arrays. The build pipeline compiles both configurations and merges the output.

For correlation ID propagation patterns across async boundaries, which often involve iteration over trace contexts, see the Correlation IDs for AI Agent Tracing post.

The most pragmatic approach for new projects in 2026 is to avoid the problem entirely by setting target: es2015 or higher. Modern browsers that support Map and Set also support native iteration. Legacy browser support increasingly relies on polyfills for missing APIs rather than transpiling syntax. The downlevelIteration flag becomes irrelevant when the target runtime includes Symbol.iterator natively.

Frequently Asked Questions

Does enabling downlevelIteration affect projects that do not use Map or Set?

Enabling downlevelIteration when iterating only arrays has minimal impact. The __values helper checks for Symbol.iterator and falls back to index-based iteration when absent, producing the same behavior as the default transpilation. The cost is the helper function's bundle size, typically 400-600 bytes per file.

Can I use downlevelIteration with target es2015 or higher?

Yes, but the flag has no effect when the target runtime natively supports Symbol.iterator. TypeScript emits native for...of loops and spread operators when target: es2015 or higher, bypassing the iteration helpers entirely. The flag only matters for target: es5 or target: es3.

Why does TypeScript not enable downlevelIteration by default?

The default is false for backward compatibility and bundle size. Enabling the flag globally increases output size for all files that use iteration, even when iterating arrays. Projects that do not use Map or Set pay the bundle size cost without gaining correctness. TypeScript prioritizes smaller default output over automatic correctness in edge cases.

How do I detect iteration bugs caused by missing downlevelIteration at build time?

TypeScript's compiler does not warn when iteration fails at runtime due to missing downlevelIteration. Static analysis tools like Biome can detect some patterns, but comprehensive detection requires runtime testing on the target environment. The most reliable approach is running integration tests on the actual transpiled output in an ES5 environment like IE11 or a headless browser configured to ES5 mode.

What happens when downlevelIteration is enabled but Symbol.iterator is not polyfilled?

The __values helper checks for Symbol.iterator...