Skip to main content
ASoc
Tutorial

forEach in TypeScript: 4 Uses Against 149 Maps

A census of a real TypeScript codebase: 4 forEach calls, 149 .map() and 137 for...of. What the four have in common, and the async trap that explains the ratio.

The ASoc Team8 min read

Every guide to forEach in TypeScript shows the signature and moves on. A more useful question is how often a working TypeScript codebase actually reaches for it. In this storefront — 92 component files, 207 blog posts, a 111-product catalog — .forEach( appears 4 times in src/, against 149 uses of .map() and 137 for...of loops. Those four are the cases where forEach is genuinely the right call, and they have something in common.

The short answer

forEach calls a function once per array element and returns undefined. In TypeScript the element type is inferred from the array, so the callback needs no annotation. Use it only for side effects: if you want a new array use .map(), and if you need await, break or continue, use for...of instead.

The signature TypeScript actually gives you

const frameworks = ["next", "react", "vue", "svelte"];

frameworks.forEach((framework, index, all) => {
  console.log(`${index + 1}/${all.length}: ${framework}`);
});

Three parameters, all optional: the element, its index, and the array itself. The element is typed from the array — frameworks is string[], so framework is string with no annotation needed. Annotating it anyway is the most common noise in TypeScript forEach code:

// Redundant — TypeScript already knows.
frameworks.forEach((framework: string) => { /* ... */ });

The one thing TypeScript will not help with is the return value. forEach is typed void, so returning something from the callback compiles silently and does nothing:

// Compiles. Returns undefined. The strings go nowhere.
const upper = frameworks.forEach((f) => f.toUpperCase());
//    ^? const upper: void

That single fact is the source of most forEach bugs, and it is why the census below leans so hard toward .map().

What a real codebase reaches for

Counted across src/ in this repository with a grep, not estimated:

IdiomUses in src/What it is for
.map()149Building a new array — every rendered list in the UI
for...of137Loops that await, break, or accumulate
.forEach()4Side effects over a small array, nothing returned

149-to-4 is not a style preference. It is what falls out of writing React: a component maps data to elements, so .map() is the default and forEach is what is left over when a loop genuinely produces nothing.

All four real uses, and the pattern in them

Three of the four are the same shape — writing cookies. From src/proxy.ts, which runs ahead of nearly every request to refresh the auth session:

setAll: (cookiesToSet) => {
  cookiesToSet.forEach(({ name, value }) =>
    request.cookies.set(name, value),
  );
  response = NextResponse.next({ request });
  cookiesToSet.forEach(({ name, value, options }) =>
    response.cookies.set(name, value, options),
  );
},

And the same call in src/lib/supabase/server.ts, wrapped in a try because a Server Component render has read-only cookies:

setAll: (cookiesToSet) => {
  try {
    cookiesToSet.forEach(({ name, value, options }) =>
      cookieStore.set(name, value, options),
    );
  } catch {
    // Called from a Server Component render (cookies are read-only there);
    // the proxy refreshes the session cookie instead.
  }
},

Note the destructuring in the parameter position — ({ name, value, options }). TypeScript infers each field's type from the array's element type, so this stays fully typed with no annotation anywhere. That is forEach at its best: a handful of items, a void operation on each, nothing to collect.

The fourth is in src/components/molecules/DownloadMenu.tsx, and it uses the index parameter for something real:

options.forEach((option, index) => {
  window.setTimeout(() => {
    const frame = document.createElement("iframe");
    frame.hidden = true;
    frame.src = option.href;
    document.body.appendChild(frame);
    window.setTimeout(() => frame.remove(), FRAME_LIFETIME_MS);
  }, index * STAGGER_MS);
});

An owner clicking "download everything" gets one hidden iframe per edition, staggered by index * STAGGER_MS because the rate-limit RPC serializes per user on an advisory lock — firing four at once would only make them queue. The loop schedules four timers and returns nothing. There is no array to build, so there is nothing for .map() to do.

forEach vs. the alternatives

.forEach().map()for...ofclassic for
Returnsundefineda new arraynothingnothing
await inside worksno (see below)noyesyes
break / continuenonoyesyes
Index availableyes, 2nd paramyes, 2nd paramvia .entries()yes
Skips empty slotsyesyesnono
Typical use hereside effects, 4 sitesrendering lists, 149 sitesasync + control flow, 137 sitesrare

The async trap

This is the failure that sends people to search for "typescript foreach" in the first place. forEach ignores the promise its callback returns, so the loop finishes before any of the work does:

// WRONG — logs "done" before a single product is checked.
products.forEach(async (product) => {
  await verifyScreenshots(product);
});
console.log("done");

TypeScript does not flag this, because async (product) => {...} returns Promise<void> and forEach accepts a callback returning voidPromise<void> is assignable to void by design. The fix is for...of when the work must be sequential:

for (const product of products) {
  await verifyScreenshots(product);
}
console.log("done");

…or Promise.all with .map() when it can be concurrent:

await Promise.all(products.map((p) => verifyScreenshots(p)));

The 137-to-4 ratio between for...of and forEach in this codebase is largely this rule applied consistently: anything that touches Supabase, the filesystem or a fetch is for...of or Promise.all, never forEach.

Typing notes worth knowing

Readonly arrays work unchanged. readonly string[] and as const tuples both have forEach, since it never mutates the array:

const CLUSTERS = ["tutorial", "comparison", "guide"] as const;
CLUSTERS.forEach((c) => console.log(c));
//                ^? const c: "tutorial" | "comparison" | "guide"

Narrowing does not survive the callback boundary. If you narrowed a variable before the loop and reassign inside it, TypeScript re-widens — a good reason to prefer const bindings in the callback.

Map and Set have their own forEach, with a different parameter order: (value, key, map). It is the one place the callback signature changes shape, and it catches people who learned the array version first.

NodeList.forEach exists in the DOM lib, so document.querySelectorAll(...) can be iterated directly without Array.from. HTMLCollection cannot — it has no forEach at all.

Troubleshooting

SymptomCauseFix
Result of forEach is undefinedforEach returns void by design; the callback's return value is discardedUse .map() when you want the results
await inside forEach doesn't waitThe callback returns Promise<void>, which forEach ignoresfor...of for sequential work, Promise.all(arr.map(...)) for concurrent
Cannot break out of the loopforEach has no early exitfor...of with break, or .some() / .find() if you want the first match
Property 'forEach' does not exist on type 'HTMLCollection'HTMLCollection is not iterable the way NodeList isArray.from(collection).forEach(...) or use querySelectorAll
Callback parameter is anyThe array itself is any[], usually from an untyped JSON parseType the array at the boundary, not the callback
Mutating the array inside the loop skips elementsforEach visits indexes as it goes; removals shift themIterate a copy ([...arr].forEach) or filter first

Frequently asked questions

What is forEach in TypeScript? The same Array.prototype.forEach as JavaScript, with types. It calls your function once per element with (element, index, array), infers the element type from the array, and returns undefined.

Should I use forEach or map in TypeScript? .map() if you want the results, forEach if you do not. In this codebase that rule produces 149 .map() calls to 4 forEach calls, because rendering a list is the common case and a side effect over an array is the rare one.

Can I use async/await inside a TypeScript forEach loop? You can write it and it will compile, but it will not wait — forEach discards the returned promise. Use for...of with await for sequential work, or Promise.all over .map() for concurrent work.

How do I break out of a forEach loop? You cannot. return only skips the current iteration. Switch to for...of for break, or use .some() (stops on the first true) or .find() when you want one matching element.

Is forEach slower than a for loop? Marginally, from the per-element function call, and it does not matter at the sizes application code deals with. The four uses in this repository iterate arrays of two to four items; picking a loop form for readability is the right trade at that scale. Iterating a million rows is where the difference becomes measurable — and where you were probably reaching for a database anyway.

Templates in this post

ASoc Catalyst, ASoc Chain and ASoc Cognition are Next.js + Tailwind landing page templates written in the same strict TypeScript as the code above — typed data arrays, inferred callbacks, no any at the render boundary.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial12 min read

Generative Engine Optimization: Building a Site AI Answers Can Cite

An audit of our own site against the standard, including the two places it falls short and the checklist item we deliberately do not ship. The unit of success is a passage, not a page.

Read more
Tutorial8 min read

Building an HTML/CSS Navigation Bar (and the Bug That Hides It)

Six ingredients build an accessible nav bar — and this site's own mobile drawer shipped the pointer-events-none bug that leaves closed links keyboard-reachable.

Read more
Tutorial9 min read

HTML Nav: The Element, Not the Layout

A real production nav, from a 4-item desktop bar to a slide-out drawer — the aria-expanded toggle, the z-index stacking, and what a div-only navbar loses.

Read more