
The most common forEach mistakes in TypeScript
forEach looks like the simplest array method there is, but in TypeScript it hides real traps: async callbacks TypeScript won't flag, narrowing lost inside closures, implicit-any indexing.
Array.prototype.forEach is probably the first array method most people learn, which is exactly why it keeps getting used where it isn’t the right tool. In TypeScript specifically, there are at least three traps the type checker won’t warn you about the way you’d expect — not because of a compiler bug, but because of how its rules are actually designed.
1. An async callback in forEach compiles fine (and that should worry you)
This is the sneakiest case because it compiles with zero warnings:
async function processAll(items: string[]) {
items.forEach(async (item) => {
await saveToDatabase(item); // runs, but nobody's waiting for this
});
console.log("done"); // logged BEFORE the saves finish
}
forEach’s signature expects a callback that returns void. An async function always returns a Promise, so technically the return type isn’t void — yet TypeScript doesn’t complain. The reason is a specific language rule: when the expected return type is void, TypeScript allows the provided function to return anything, as long as the value is ignored. That’s a deliberate design choice meant for cases like event handlers (onClick={() => setCount(c => c + 1)}, where the return value doesn’t matter), but it has the side effect of making a real bug invisible: forEach doesn’t wait for Promises, so the calls to saveToDatabase all fire off more or less at once with no control over ordering, and any rejection isn’t caught by any surrounding try/catch.
The fix: if you need await inside the loop, forEach is the wrong tool. Use for...of:
async function processAll(items: string[]) {
for (const item of items) {
await saveToDatabase(item);
}
console.log("done"); // now this is actually true
}
Or, if you want to run in parallel but still wait for completion:
await Promise.all(items.map((item) => saveToDatabase(item)));
The @typescript-eslint/no-misused-promises rule (also available via eslint-plugin-oxlint, if you use oxlint) exists specifically to catch this pattern, because the compiler on its own won’t.
2. return inside forEach is not a break
A common conceptual mistake for anyone coming from a classic for loop:
function findFirstEven(numbers: number[]) {
numbers.forEach((n) => {
if (n % 2 === 0) {
return n; // does NOT exit forEach, only the current iteration
}
});
}
TypeScript flags nothing wrong here — syntactically it’s entirely valid, it’s just that return inside the callback behaves like continue, not break. The returned value is discarded (consistent with point 1: forEach expects void), and the loop keeps going through every element even after you’ve already found what you were looking for.
The fix: if you need an early exit, forEach doesn’t support one. You need a for...of loop with break, or Array.prototype.find:
const firstEven = numbers.find((n) => n % 2 === 0);
3. Narrowing gets lost inside the callback
A less-known but real case, tied to how TypeScript reasons about function boundaries:
function process(value: string | undefined, items: string[]) {
if (typeof value === "undefined") return;
items.forEach(() => {
console.log(value.toUpperCase()); // may error here
});
}
If value isn’t const (or if TypeScript can’t guarantee it won’t be reassigned before the callback actually runs — which, with an asynchronous callback, is impossible to rule out in general), the narrowing done by the if above isn’t guaranteed to hold inside a nested function like forEach’s callback. With strict: true — the default since TypeScript 7, covered in the TypeScript 7 article — these cases surface far more often than they did under TypeScript 6’s more permissive defaults.
The fix: assign the narrowed value to a new const variable before passing it to the callback, so the type stays pinned:
function process(value: string | undefined, items: string[]) {
if (typeof value === "undefined") return;
const narrowedValue = value; // const: TypeScript can now trust it
items.forEach(() => {
console.log(narrowedValue.toUpperCase());
});
}
The lesson common to all three
In all three examples, TypeScript isn’t flagging a syntax or type error in the strict sense — it’s simply not protecting you from a logical mistake the type system was never designed to catch. forEach is a more limited API than it looks: no early exit, no native async handling, and closures that can behave differently than expected. For most cases where reaching for it feels natural, for...of, map, find, or Promise.all are more explicit choices — and harder to misuse.

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.