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.
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:
| Idiom | Uses in src/ | What it is for |
|---|---|---|
.map() | 149 | Building a new array — every rendered list in the UI |
for...of | 137 | Loops that await, break, or accumulate |
.forEach() | 4 | Side 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...of | classic for | |
|---|---|---|---|---|
| Returns | undefined | a new array | nothing | nothing |
await inside works | no (see below) | no | yes | yes |
break / continue | no | no | yes | yes |
| Index available | yes, 2nd param | yes, 2nd param | via .entries() | yes |
| Skips empty slots | yes | yes | no | no |
| Typical use here | side effects, 4 sites | rendering lists, 149 sites | async + control flow, 137 sites | rare |
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 void — Promise<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
| Symptom | Cause | Fix |
|---|---|---|
Result of forEach is undefined | forEach returns void by design; the callback's return value is discarded | Use .map() when you want the results |
await inside forEach doesn't wait | The callback returns Promise<void>, which forEach ignores | for...of for sequential work, Promise.all(arr.map(...)) for concurrent |
Cannot break out of the loop | forEach has no early exit | for...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 is | Array.from(collection).forEach(...) or use querySelectorAll |
Callback parameter is any | The array itself is any[], usually from an untyped JSON parse | Type the array at the boundary, not the callback |
| Mutating the array inside the loop skips elements | forEach visits indexes as it goes; removals shift them | Iterate 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.
