skip to content

In TypeScript, `items.forEach(async item => { await save(item); })` compiles without complaint even though the callback is asynchronous. Why does the type checker accept it, and what goes wrong when it runs?

level: seniorimportance: should knowfreq 44%

answer

  1. async callback returns a promise
  2. void target accepts any return type
  3. forEach never stores what it gets back
  4. rejection with no handler attached
  5. for-of or Promise.all over map

basics

~20 s

An async callback returns Promise<void>, and a callback type declared to return void accepts a function returning any type. Nothing consumes the promise, so forEach returns before the work finishes and any rejection becomes an unhandled rejection.

solid answer

~50 s

`forEach` declares its callback as returning `void`, and TypeScript's void-return assignability rule lets a function returning `Promise<void>` satisfy it — the caller said it would ignore the result, so the types are consistent. The problem is what is being ignored: `forEach` never stores or awaits the promises, so it returns immediately, code after it runs while the saves are still in flight, and a rejected save has no handler attached and surfaces as an unhandled rejection rather than as a catchable error. The fix depends on the semantics you want: `for (const item of items) await save(item)` for sequential work, or `await Promise.all(items.map(item => save(item)))` for concurrent work with a single point of failure. If you own the callback type, declare it `() => void | Promise<void>` so implementers can see the promise is awaited; typescript-eslint's `no-misused-promises` rule catches the mistake mechanically.

code

typescript · 21 lines
typescript
declare function save(item: string): Promise<void>;
const items = ['a', 'b', 'c'];

async function broken() {
  // Compiles: async callback returns Promise<void>, forEach wants void.
  items.forEach(async item => { await save(item); });
  return 'returns before any save finishes';
}

async function sequential() {
  for (const item of items) {
    await save(item); // errors propagate to the caller
  }
}

async function concurrent() {
  // map keeps the promises instead of discarding them
  await Promise.all(items.map(item => save(item)));
}

void broken; void sequential; void concurrent;

go deeper

for a junior

Know that forEach does not wait for an async callback, and that awaiting a list of operations means for...of with await or Promise.all over the mapped promises.

for a middle

Explain the type-level reason it compiles: an async function returns Promise<void>, and a void-returning callback type accepts any return type by design.

for a senior

Show the production consequences — partial completion, unbounded fan-out, unhandled rejections invisible to a surrounding try/catch — and pick sequential versus concurrent deliberately.

for a principal

Own the systemic fix: type-aware lint rules enabled repo-wide, callback contracts in shared code that admit promises, and a policy for the process-level unhandled-rejection behaviour of your services.

## Why the compiler is silent Two separate facts combine here, and both are working as designed. First, an `async` function always returns a promise. `async item => { await save(item); }` has type `(item: Item) => Promise<void>`, not `(item: Item) => void`. Second, TypeScript has a deliberate assignability rule: a function type whose return type is `void` accepts a function returning *any* type. `Array.prototype.forEach` declares its callback as `(value: T, index: number, array: T[]) => void`, so `Promise<void>` slots in without comment. The rule exists so that ordinary callbacks with concise bodies do not need braces added just to swallow a return value. The rule's premise is "the caller ignores the result, and ignoring a result is safe". That premise holds for a number. It does not hold for a promise, because a promise is not a result — it is a *handle to work still in progress*, and to a rejection that needs somewhere to go. ```ts declare function save(item: Item): Promise<void>; items.forEach(async item => { await save(item); }); console.log('done'); // prints before a single save has finished ``` ## What actually happens at runtime The emitted JavaScript is unchanged by types, so `forEach` does what it always does: it calls the callback once per element and throws the return value away. - **Ordering and completion.** Every call starts, none is waited on. `forEach` returns as soon as the last callback has been *invoked*, not completed. Any code after the loop — a commit, a response, a log line saying "done" — runs against a half-finished state. - **Concurrency.** All the saves are launched at once. With ten items that is fine; with ten thousand you have just opened ten thousand concurrent operations against a database or an API with no limit. - **Errors.** This is the sharp edge. A `throw` inside an async function rejects its promise instead of propagating to the caller, so a surrounding `try/catch` around the `forEach` catches nothing. Nobody attached a handler to the discarded promise, so the rejection is unhandled: in Node it triggers the process-level unhandled-rejection behaviour, and in a browser it fires an `unhandledrejection` event and is usually only visible in the console. ```ts try { items.forEach(async item => { await save(item); }); // throws inside → rejection } catch (e) { // never reached } ``` So the failure mode is the worst kind: silent partial success, with the error visible only in a place nobody is watching. ## Fixing it Decide first whether the work should be sequential or concurrent — the two fixes are not interchangeable. **Sequential**, when order matters or you must not overwhelm the downstream service: ```ts for (const item of items) { await save(item); } ``` A `for...of` loop inside an async function awaits each iteration, errors propagate normally to an enclosing `try/catch`, and the loop stops at the first failure. **Concurrent**, when the calls are independent: ```ts await Promise.all(items.map(item => save(item))); ``` `map` returns the array of promises rather than discarding it — that is the whole difference — and `Promise.all` gives you one awaited result that rejects on the first failure. Use `Promise.allSettled` when you want every item attempted and a report of which failed, and add a concurrency limit when the fan-out is large. Note that `map(item => save(item))` needs no `async` at all: returning the promise is exactly what is wanted. Writing `async item => await save(item)` there is harmless but adds a wrapper for nothing. ## Preventing the class of bug **Type the callback honestly.** If you are designing an API whose callback will be awaited, do not declare it `() => void` — say `() => void | Promise<void>` if both are acceptable, or `() => Promise<void>` if you require one. The `void` declaration is a statement that the result is meaningless, and for an awaited callback that statement is false. **Let a linter carry the rule.** The type checker cannot flag this without breaking the void-return rule that legitimate code depends on, so the check lives in typescript-eslint: `no-misused-promises` reports a promise-returning function passed where a void-returning one is expected, and `no-floating-promises` reports a promise-valued expression whose result is never consumed. These are type-aware rules and catch the same shape in event handlers, `setTimeout` callbacks and array methods alike. **Watch the same shape elsewhere.** Anything typed to take a `() => void` callback has this hole: DOM event handlers, `setInterval`, middleware hooks, and framework lifecycle callbacks. The compiler will accept an async function in every one of them.

  • Why doesn't a try/catch wrapped around the forEach call catch a failing save?
    Because the throw happens inside an async function, which converts it into a rejected promise rather than an exception that unwinds through the caller. By the time it rejects, `forEach` has already returned and the `try` block has exited. The only way to catch it is to hold the promise — via `await` or `.catch()` — which is exactly what `forEach` discards.
  • Is `await Promise.all(items.map(...))` always the right replacement?
    No — it starts every operation at once. That is right for a handful of independent calls, wrong for ten thousand rows hitting a database, and wrong whenever order matters. Use a sequential `for...of` when order or backpressure matters, `Promise.allSettled` when every item must be attempted regardless of failures, and a bounded-concurrency helper when the list is large.
  • If you control the callback's type, what should it say?
    Say what you will actually do with the result. `() => Promise<void>` requires an async implementation and makes clear you await it; `() => void | Promise<void>` accepts both and signals that a returned promise is honoured. Declaring `() => void` while awaiting the result is a lie, and declaring it while ignoring the result is honest but leaves the trap open for callers.

saying these in an interview costs you the question

  • Says forEach awaits each async callback
  • Believes a surrounding try/catch will catch the rejection
  • Thinks the compiler should have rejected the async callback
  • Replaces forEach with map and forgets to await
  • Calls it a TypeScript bug rather than the void-return rule

context