Array iteration methods
The JavaScript array methods for transforming, filtering, and aggregating, and how to tell when a for...of loop is the better reach.
Two bugs frame this lesson, failing in opposite directions: one from not reaching for the array methods, the other from reaching for them where a plain loop was right. The running example is an array of invoice rows, { id, amountCents, status, customerId, dueDate } with status: 'paid' | 'pending' | 'overdue'.
const invoiceTotal = (invoices: Invoice[]): number => { let total = 0; for (let i = 0; i < invoices.length; i++) { total += invoices[i]!.amountCents; } return total;};The first function isn’t broken: it returns the right number, but in a style the codebase doesn’t use. A C-style for loop with a hand-rolled accumulator and a non-null assertion is what you reach for before the array methods are second nature. The one-line invoices.reduce((sum, i) => sum + i.amountCents, 0) states the intent, “fold the list into a single number”; the loop states only the mechanics.
const sendOverdueReminders = (invoices: Invoice[]) => { invoices .filter((i) => i.status === 'overdue') .map(async (i) => { await fetch(`/api/notify/${i.customerId}`, { method: 'POST' }); });};The second function is the same habit over-applied. .map(async ...) looks like it awaits each request, but .map awaits nothing: each callback returns a Promise<void>, and .map collects them into a Promise<void>[] the function never uses. Every fetch fires in parallel, the promises are discarded, and the caller can’t know when, or whether, the work finished. The right form is a for...of loop that awaits each request before the next, or Promise.all if parallel was the intent.
The previous lesson covered the container surface on arrays: indexing, copying, and non-mutating updates. This lesson covers the element-by-element surface: eight methods that walk an array to produce a value, plus the four signs to drop the chain for a for...of loop, so you can pick the right form on sight.
Eight methods, five output shapes
Section titled “Eight methods, five output shapes”Don’t memorize the eight names; group them by what they produce. Name the output shape your operation needs and the right method falls out.
| Output shape | Methods | What each returns |
|---|---|---|
| Transform | .map, .flatMap | a new array, same length (or expanded / contracted by .flatMap) |
| Subset | .filter | a new array of items that pass the predicate |
| Fold | .reduce, .reduceRight | a single value built up from every element |
| Search / test | .find, .findIndex, .findLast, .findLastIndex, .some, .every | the first/last match, an index, or a boolean (short-circuits) |
| Side effect | .forEach | undefined; runs the callback for each item |
Ask “what shape comes out?” and the row points you at the method. The twist is the last row: even when you want only a side effect and no value, you almost never reach for .forEach, for reasons the rest of the lesson explains.
Transform: .map and .flatMap
Section titled “Transform: .map and .flatMap”.map is the most-used method here. Its job is “I have a list of A, I want a list of B”: rows into view-models, records into IDs.
const amounts = invoices.map((i) => i.amountCents);amounts is number[], same length as invoices, one entry per row, inferred with no annotation.
.flatMap is easy to overlook. Its callback returns an array per element, and .flatMap concatenates those into one flat result. The power is in what you return: [] to drop an item, [a, b] to expand one into two.
const overdueIds = invoices.flatMap((i) => i.status === 'overdue' ? [i.id] : [],);That collapses invoices.filter((i) => i.status === 'overdue').map((i) => i.id) into a single walk; ? [i.id] : [] reads as “keep this one as its id, or drop it.” It pays off most when the filter and the transform share a per-item computation, which .flatMap does once instead of recomputing in both.
.flat(depth) is the narrower tool: it flattens a nested array without touching the elements, so [[1, 2], [3]].flat() gives [1, 2, 3].
Subset: .filter and the type predicate
Section titled “Subset: .filter and the type predicate”.filter keeps the items the predicate returns true for, producing a new array of the same element type:
const overdue = invoices.filter((i) => i.status === 'overdue');overdue is Invoice[]: the same shape as invoices, fewer rows.
The harder case is when .filter should also narrow the element type. Filter the nulls out of a (string | null)[] and the result is a string[] at runtime, but TypeScript won’t narrow it without proof. That proof is a type predicate , an annotation that lifts a runtime check into the type system. One shape fails to narrow and three succeed, in order of preference:
const rawIds: (string | null)[] = ['inv_1', null, 'inv_2', null, 'inv_3'];
const stillNullable = rawIds.filter(Boolean);const inferred = rawIds.filter((x) => x !== null);const explicit = rawIds.filter((x): x is string => x !== null);
const isPresent = <T,>(x: T | null | undefined): x is T => x != null;const helper = rawIds.filter(isPresent);The surprise people hit most. TypeScript 5.5+ won’t infer a predicate from Boolean, since 0, '', and false are falsy yet valid, so stillNullable stays (string | null)[].
const rawIds: (string | null)[] = ['inv_1', null, 'inv_2', null, 'inv_3'];
const stillNullable = rawIds.filter(Boolean);const inferred = rawIds.filter((x) => x !== null);const explicit = rawIds.filter((x): x is string => x !== null);
const isPresent = <T,>(x: T | null | undefined): x is T => x != null;const helper = rawIds.filter(isPresent);The day-to-day form. Since TS 5.5, which the course pins, a simple x !== null check is read as a type predicate automatically, so inferred narrows to string[] with no annotation.
const rawIds: (string | null)[] = ['inv_1', null, 'inv_2', null, 'inv_3'];
const stillNullable = rawIds.filter(Boolean);const inferred = rawIds.filter((x) => x !== null);const explicit = rawIds.filter((x): x is string => x !== null);
const isPresent = <T,>(x: T | null | undefined): x is T => x != null;const helper = rawIds.filter(isPresent);The same narrowing with the intent spelled out: x is string says “if this returns true, treat x as a string.” Reach for it when the predicate is too complex to infer, or to make the narrowing visible at the call site.
const rawIds: (string | null)[] = ['inv_1', null, 'inv_2', null, 'inv_3'];
const stillNullable = rawIds.filter(Boolean);const inferred = rawIds.filter((x) => x !== null);const explicit = rawIds.filter((x): x is string => x !== null);
const isPresent = <T,>(x: T | null | undefined): x is T => x != null;const helper = rawIds.filter(isPresent);A reusable helper: write is T once, every caller narrows for free. The trailing comma in <T,> keeps a generic arrow function parsing under .tsx.
The type predicate is the core TypeScript idea here, and it returns on .find; chapter 5 covers user-defined type guards in full. To drop null or undefined, reach for (x) => x !== null or isPresent, never .filter(Boolean), which misleads on both the type it produces and the values it drops.
Fold: .reduce, and when to use something else
Section titled “Fold: .reduce, and when to use something else”.reduce folds an array into one built-up value: the callback walks the array carrying an accumulator, and the result is the accumulator after the last element.
const total = invoices.reduce((sum, i) => sum + i.amountCents, 0);The 0 is the initial accumulator. Each step takes the running sum and the current invoice and returns the next sum, so the final value is the total in integer cents.
Two rules keep .reduce healthy.
Always pass the initial value. Omit the second argument and the first element becomes the accumulator, which makes the typing awkward (especially under noUncheckedIndexedAccess) and runs the callback one fewer time than you expect. Write , 0 for sums, , [] for arrays, , {} for objects.
Reach for a specialized method when one fits. "Is there at least one?" is .some, which short-circuits; "are they all?" is .every; "the first match?" is .find. .reduce is the right reach only when the output is something none of those produce: a sum, an aggregate object, a min or max.
One pitfall survives all of this: reducing to an object.
const byId = invoices.reduce<Record<string, Invoice>>( (acc, i) => ({ ...acc, [i.id]: i }), {},);It reads fluently but runs in O(n²). The spread copies the whole accumulator every iteration (1 key, then 2, up to n, across n iterations), so a thousand rows means half a million extra copies, ten thousand crawls, and nothing warns you because the result is still correct.
const byId = Object.fromEntries(invoices.map((i) => [i.id, i]));Object.fromEntries plus .map is the linear form. One walk builds the [key, value] pairs and Object.fromEntries drops them into the object with no accumulator copying; when the key is a non-string or you want .get and .has, reach for a Map, which the next lesson covers.
Search and test: stop at the first answer
Section titled “Search and test: stop at the first answer”Six methods walk the array only as far as the answer requires, then stop.
const target = invoices.find((i) => i.id === 'inv_001');const latestOverdue = invoices.findLast((i) => i.status === 'overdue');const hasOverdue = invoices.some((i) => i.status === 'overdue');const allPaid = invoices.every((i) => i.status === 'paid');.find(predicate) returns the first match or undefined, so handle the miss with ?? or a guard. Like .filter, it accepts a type predicate, so arr.find((x): x is Invoice => ...) narrows the result to Invoice | undefined.
.findIndex(predicate) returns the index or -1. Reach for it when the position matters: slicing, or paired updates with .with(i, ...) from the previous lesson.
.findLast and .findLastIndex walk from the end, for “the most recent event matching X” without a .reverse() first.
.some(predicate) short-circuits to true the moment the predicate fires. Prefer it over arr.filter(fn).length > 0: it stops early, reads as the question being asked, and allocates no intermediate array.
.every(predicate) is the inverse, short-circuiting to false the moment one fails. Reach for it to check invariants, such as “are all invoices paid?”
.indexOf(value) and .includes(value) match an exact value in a primitive array; .includes is the form for “is this string in this short list?” For larger lookups or non-primitive comparison, reach for a Set, which the next lesson covers.
.forEach runs callbacks but ignores await
Section titled “.forEach runs callbacks but ignores await”.forEach((item) => { ... }) runs the callback for each item for its side effects and returns undefined. You’ll rarely reach for it, for one reason that isn’t style: it ignores await.
invoices.forEach(async (i) => { await fetch(`/api/notify/${i.id}`);});// all fetches fire in parallel; none are awaited at this call siteThe async callbacks all start in parallel, .forEach returns undefined synchronously, and the next line runs before any fetch lands. For sequenced async work use for...of with await; for deliberate fan-out use Promise.all(arr.map(async ...)). Both are covered next.
.forEach earns its keep only for a short, synchronous side effect at the tail of a chain, such as results.forEach((r) => console.log(r)), and even there a one-line for...of works just as well.
Four signs to reach for for...of
Section titled “Four signs to reach for for...of”The method chain is your default; reach for for...of on these four signs.
-
Async work that needs sequencing.
.map(async ...)returns promises that run in parallel;.forEach(async ...)ignores them. When each step must await the previous one, loop withawaitin the body. -
Early termination (
break,return). Every array method outside the search/test family walks the whole array;for...ofcanbreakthe moment a condition fires. Reach for it to find the first match and stop when the loop also runs side effects, so.findwon’t do. -
Multiple statements per iteration. A body of three or four statements, or one with its own local variables, reads clearly in
for...ofbut buries the control flow in a.forEachcallback. -
Need both index and value.
arr.entries()yields[index, value]pairs:for (const [i, item] of arr.entries()) { ... }..map((item, i) => ...)is fine when the index is just one more argument;.entries()is clearer when the body uses both.
The first sign is the one you’ll hit most. Here are the async forms side by side.
await Promise.all( invoices.map(async (i) => { await fetch(`/api/notify/${i.id}`, { method: 'POST' }); }),);Every request fires at once; Promise.all resolves when the last one lands. Reach for this when one piece doesn’t depend on the previous result: notifications, fetching independent records.
for (const i of invoices) { await fetch(`/api/notify/${i.id}`, { method: 'POST' });}Each request waits for the previous one to land. Reach for this when ordering matters, each call depends on the last, or you need to throttle a downstream API. .forEach(async ...) is the broken third variant: it fires in parallel while the outer code awaits nothing.
Promise.all(arr.map(async ...)) for parallel, for...of with await for sequenced; .forEach(async ...) exists only to be flagged in review.
Name the intermediate, and reach for Set
Section titled “Name the intermediate, and reach for Set”Two judgment calls are left, neither tied to a single method: when to break a chain apart for readability, and when to swap a slow lookup for a fast one.
Name the intermediate
Section titled “Name the intermediate”Two or three chained methods read clearly. Four or more hide what each step produces. The fix isn’t a comment, it’s pulling the intermediate results into named consts that state their intent.
const totalOverdueEUR = invoices .filter((i) => i.status === 'overdue') .map((i) => ({ ...i, currency: i.customerId.startsWith('EU') ? 'EUR' : 'USD' })) .filter((i) => i.currency === 'EUR') .reduce((sum, i) => sum + i.amountCents, 0);Four methods in one chain. To follow it you have to track the array’s shape at every step. The intent, “total overdue invoices in EUR,” is buried under the mechanics.
const overdueInvoices = invoices.filter((i) => i.status === 'overdue');const overdueEurInvoices = overdueInvoices.filter((i) => i.customerId.startsWith('EU'),);const totalOverdueEUR = overdueEurInvoices.reduce( (sum, i) => sum + i.amountCents, 0,);The same result, with three named bindings. Each line is one step, and the names carry the meaning the chain hid. The .map is gone too: it computed currency only to filter on it once, so the second filter now checks customerId.startsWith('EU') inline.
Once a chain runs past three links, name the steps.
Drop into a Set when the inner check is membership
Section titled “Drop into a Set when the inner check is membership”The pattern to spot is .filter(x => other.includes(x.id)). With a 10k-row arr and a 200-id other, that’s O(n × m), two million comparisons for what should be a single walk. Build a Set of the IDs once, outside the filter:
const otherIds = new Set(other.map((o) => o.id));const matching = arr.filter((x) => otherIds.has(x.id));Each lookup drops from O(m) to O(1), and the whole operation from O(n × m) to O(n + m).
Narrow the array with a type predicate
Section titled “Narrow the array with a type predicate”Two .filters, two inferred types. Leave the first as is, since .filter(Boolean) does not narrow. Rewrite the second so presentIds is string[], not (string | null)[].
Two .filters, two different inferred types. The top line is a witness to the .filter(Boolean) footgun — leave it as is. Rewrite the bottom filter so presentIds is string[], not (string | null)[]. Hint: TS 5.5+ infers a type predicate from a simple non-null check, but explicitly excludes truthiness checks like Boolean.
-
Type query at line 4 must resolve to a type containing
(string | null)[] -
Type query at line 7 must resolve to a type containing
string[]
Refactor a tangled chain
Section titled “Refactor a tangled chain”A teammate’s PR. The function should fetch a status for each overdue invoice, sequence the requests so the API isn’t hit in parallel, and return how many need a reminder. Find the bugs and comment on each.
Review this PR for a teammate. The function is supposed to fetch a status for each overdue invoice, sequence the requests so we don't hammer the API in parallel, and return the count of those that need a reminder. Two bugs to flag — leave a comment on each. Click any line to leave a review comment, then press Submit review.
type Invoice = { id: string; amountCents: number; status: 'paid' | 'pending' | 'overdue'; customerId: string;};
export const processOverdue = async (invoices: Invoice[]): Promise<number> => { let count = 0; invoices .filter((i) => i.status === 'overdue') .map((i) => ({ ...i, key: i.id })) .filter((i) => i.amountCents > 0) .forEach(async (i) => { const res = await fetch(`/api/customers/${i.customerId}/status`); const { needsReminder } = await res.json(); if (needsReminder) count += 1; }); return count;};.forEach ignores the promise the callback returns: the fetches fire in parallel and processOverdue resolves with count === 0 before any land. It also can’t sequence the requests, which the instructions asked for. Use for...of with await:
for (const i of overdueInvoices) { const res = await fetch(`/api/customers/${i.customerId}/status`); const { needsReminder } = await res.json(); if (needsReminder) count += 1;}The .map adds a key field nothing reads, and the four-link .filter().map().filter().forEach() hides the intent. Drop the dead .map and name the intermediate:
const overdueInvoices = invoices.filter( (i) => i.status === 'overdue' && i.amountCents > 0,);The pattern is the combination: a fluent chain ending in an async .forEach, both halves from the same instinct to use .map / .filter / .forEach for everything. Reach for for...of when the work needs sequencing, and name the intermediate when a chain hides its intent. The async .forEach is the dangerous one: it looks correct and silently returns the wrong number in production.
Further reading
Section titled “Further reading”Reference for the under-known transform, with the `[]`-to-drop and `[a, b]`-to-expand idiom worked through.
The long-form callback signature reference, with the initial-value rule called out explicitly.
The release-notes section that introduced the inferring-predicate surface for `.filter`, the modern narrowing default the course pins on.