Skip to content
Chapter 3Lesson 3

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.

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 shapeMethodsWhat each returns
Transform.map, .flatMapa new array, same length (or expanded / contracted by .flatMap)
Subset.filtera new array of items that pass the predicate
Fold.reduce, .reduceRighta single value built up from every element
Search / test.find, .findIndex, .findLast, .findLastIndex, .some, .everythe first/last match, an index, or a boolean (short-circuits)
Side effect.forEachundefined; 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.

.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].

.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.

1 / 1

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.

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((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 site

The 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.

The method chain is your default; reach for for...of on these four signs.

  1. 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 with await in the body.

  2. Early termination (break, return). Every array method outside the search/test family walks the whole array; for...of can break the moment a condition fires. Reach for it to find the first match and stop when the loop also runs side effects, so .find won’t do.

  3. Multiple statements per iteration. A body of three or four statements, or one with its own local variables, reads clearly in for...of but buries the control flow in a .forEach callback.

  4. 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.

Promise.all(arr.map(async ...)) for parallel, for...of with await for sequenced; .forEach(async ...) exists only to be flagged in review.

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.

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.

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).

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[]
Booting type-checker…

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.

src/invoices/process.ts
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;
};