Skip to content
Chapter 3Lesson 3

The array method surface

The JavaScript array iteration methods every transform, filter, and aggregate in this course is built on, and how to know when a for...of loop is the better reach.

Here are two bugs that sit at opposite ends of one spectrum. The running example for this lesson is an array of invoice rows, { id, amountCents, status, customerId, dueDate } with status: 'paid' | 'pending' | 'overdue', the same domain as the previous two lessons. The first bug comes from not reaching for the array methods; the second comes from reaching for them when a plain loop was the right tool.

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 it’s written in a style the rest of the codebase doesn’t use. A C-style for (let i = 0; ...) loop, with a hand-rolled accumulator and a non-null assertion to silence the strict-index error, is the form a developer reaches for before the array methods have become second nature. The one-line replacement, invoices.reduce((sum, i) => sum + i.amountCents, 0), states the intent, “fold the list into a single number,” where 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 opposite habit, over-applied. The chain looks fluent, and the .map(async ...) looks like it awaits each request, but .map doesn’t await anything. The callback returns a Promise<void> per row, and .map collects those into a Promise<void>[] that the function never uses. So all the fetches fire in parallel and the array of promises is thrown away, which leaves the caller no way to know when, or whether, the work finished. The right form here isn’t a chain at all. It’s a for...of loop that awaits each request before starting the next, or Promise.all if parallel was the intent.

The previous lesson covered the container surface on arrays: indexing, copying, and the non-mutating update family. This lesson covers the element-by-element surface: eight methods that walk an array and produce a value, plus the four signs that tell you to drop the chain in favor of a for...of loop. By the end you should be able to pick the right form on sight, without running through the whole catalog in your head.

The way to keep these eight methods straight isn’t to memorize the names. It’s to group them by what they produce. Once you can name the output shape your operation needs, the right method falls out of the group.

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

The question to ask is “what shape comes out?” If it’s a list of the same things transformed, you want a transform. If it’s a smaller list of the same things, you want subset. If it’s a single value built from all of them, you want fold. If it’s one element or a boolean, you want search/test. And if it’s nothing at all, because you only want a side effect, you almost never want .forEach, for reasons the rest of the lesson explains.

.map is the most-used method on this surface. Its job is “I have a list of A, I want a list of B”: every transform from rows to view-models, every props derivation, every walk that turns a list of records into a list of IDs.

const amounts = invoices.map((i) => i.amountCents);

amounts is number[], the same length as invoices, with one entry per source row. Inference does all the work.

.flatMap is the one most people overlook until they’ve seen the idiom for it. The callback returns an array per element, and .flatMap concatenates all those arrays into one flat result. The trick is in the array you return: return [] to drop an item, or return [a, b] to expand one item 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. The ? [i.id] : [] shape reads as “keep this one as its id, or drop it.” This pays off most when the filter and the transform share a per-item computation: .flatMap does both in one pass, instead of computing a value in the .filter only to recompute it in the .map.

One footnote: .flat(depth) exists for the narrower job of flattening a nested array without touching the elements, so [[1, 2], [3]].flat() gives [1, 2, 3]. It’s worth recognizing, but .flatMap is the more common reach.

.filter keeps the items the predicate returns true for. The output is a new array of the same element type:

const overdue = invoices.filter((i) => i.status === 'overdue');

overdue is Invoice[], the same shape as invoices, just with fewer rows. That part is simple.

The interesting case is when .filter is also supposed to narrow the element type. Say you have (string | null)[] and you want to filter out the nulls. At runtime the result is a string[], but TypeScript needs convincing that the filter really does that narrowing. The tool for that is a type predicate , an annotation that carries a runtime check up into the type system. There are three shapes worth recognizing, 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);

A truthiness check, with no narrowing. TypeScript 5.5+ explicitly excludes Boolean from inferred type predicates, because !!x doesn’t track the type: 0, '', and false are all “absent” to Boolean, yet they’re valid values of their types. So stillNullable stays typed as (string | null)[]. This is the surprise people hit most often on this surface, so keep it in mind.

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

An inferred type predicate, and the one you’ll reach for day to day. Since TS 5.5 (June 2024), a simple non-null check like x !== null is recognized as a type predicate automatically, so inferred narrows to string[] with no annotation at all. The course pins TypeScript at 5.5+, so this is the form you’ll write most of the time.

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

An explicit type predicate. The (x): x is string => ... return type tells TypeScript, “if this returns true, treat x as a string.” It produces the same narrowing as the inferred check above, just with the annotation spelled out. Reach for it when the predicate is more complex than the inferrer can handle on its own, or when you want the narrowing intent visible at the call site. User-defined type guards get their full treatment in chapter 5.

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 it once, import it everywhere. isPresent carries an explicit is T, so every caller gets the narrowing for free. The <T,> with the trailing comma is the standard way to write a generic on an arrow function, and it parses cleanly under .tsx.

1 / 1

The type predicate is the core TypeScript idea on this surface: the x is T return annotation tells the compiler what’s true if the function returns true. You’ll meet it again on .find shortly, and chapter 5 covers user-defined type guards in their own right.

The .filter(Boolean) mistake deserves one more pass, since it’s so easy to reach for. When the goal is filtering out null or undefined, write (x) => x !== null, or use the isPresent helper. When the goal is filtering out falsy values including 0 and '', write that exact check anyway. .filter(Boolean) misleads on two counts: about the types it produces, and about which values it actually drops.

.reduce is the fold. One callback walks the array carrying an accumulator, and the return value is whatever the accumulator looks like after the last step.

const total = invoices.reduce((sum, i) => sum + i.amountCents, 0);

The 0 is the initial accumulator. The callback receives the running sum and the current invoice and returns the next sum, and the final value is the total in integer cents.

Two rules keep .reduce in good shape.

Always pass the initial value. Omitting the second argument makes the first element the initial accumulator, which causes two problems: the typing gets awkward, especially under noUncheckedIndexedAccess, and the callback runs one fewer time than you’d expect. Write , 0 for sums, , [] for array accumulators, and , {} for object accumulators.

Reach for the specialized method when it fits. This rule matters more. If the question is “is there at least one?”, .some short-circuits and reads as exactly that question. For “are they all?” use .every, and for “the first one matching?” use .find. .reduce is the right reach only when none of those fit, which is when the output is a built-up value such as a sum, an aggregate object, or a min/max.

There’s one pitfall on .reduce that catches developers who have internalized it: reducing to an object.

const byId = invoices.reduce<Record<string, Invoice>>(
(acc, i) => ({ ...acc, [i.id]: i }),
{},
);

It looks fluent but runs in O(n²). The { ...acc, [i.id]: i } spread copies the entire accumulator on every iteration: 1, then 2, then 3, up to n keys copied across n iterations. A thousand-row list does half a million extra copies, and a ten-thousand-row list slows to a crawl. Nothing warns you, since the result is correct. Only the performance suffers, and only once the list gets large.

When you see ({ ...acc, [k]: v }, {}) in a .reduce, rewrite it to Object.fromEntries(arr.map(...)) without a second thought. The two read almost identically, but their runtime costs are worlds apart.

Six methods walk the array only as far as the answer requires. They all follow the same idea, walk until the answer is known, then stop, so one short section covers them together.

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 matching element, or undefined. It’s the reach for “the row with this id.” The return type is T | undefined, the same shape as arr[0] under noUncheckedIndexedAccess, so handle the miss with ?? or a guard. .find accepts the same type-predicate shape as .filter, so arr.find((x): x is Invoice => ...) returns Invoice | undefined rather than a wider union.

.findIndex(predicate) returns the index, or -1. Reach for it when the position matters: for slicing, for paired updates with .with(i, ...) from the previous lesson, or for any operation that needs to know where the match landed.

.findLast and .findLastIndex (ES2023, baseline-widely-available since early 2025) walk from the end. They’re the reach for “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, it reads as the question being asked, and it never allocates an intermediate array. If you find yourself counting filter results just to check whether any exist, you’ve reached for the wrong method.

.every(predicate) is the inverse: it short-circuits to false the moment a predicate fails. Reach for it to check invariants, such as “are all invoices paid?” or “do all rows have a non-null id?”

One footnote: .indexOf(value) and .includes(value) exist for searching for an exact value match on a primitive array. .includes is the modern form for “is this string in this short list?” For larger lookups or non-primitive comparison, the right reach is a Set, whose has-is-O(1) story lands in the next lesson.

.forEach((item) => { ... }) runs the callback for each item purely for side effects, and its return value is undefined. It’s worth knowing once and then mostly setting aside, because you’ll rarely reach for it.

The reason isn’t style. for...of, covered in the next section, reads at least as cleanly, supports break / continue / return inside the body, and, most importantly, works with await. .forEach doesn’t await anything; it ignores any promise the callback returns. Consider this:

invoices.forEach(async (i) => {
await fetch(`/api/notify/${i.id}`);
});
// outer code continues immediately, all fetches fire in parallel,
// none of them are awaited at this call site

The async callbacks all start in parallel, the .forEach returns undefined synchronously, and the line after it runs before any of the fetches have landed. If you wanted sequenced async work, “send the notifications one at a time and wait for each,” the form is for (const i of invoices) { await fetch(...) }. If you wanted deliberate fan-out, “send them all in parallel and wait for all to finish,” the form is await Promise.all(invoices.map(async (i) => fetch(...))). The fire-and-forget .forEach(async ...) is rarely what you meant.

The narrow case where .forEach earns its place is a short, synchronous, side-effecting callback at the tail of a chain, where keeping the chain readable matters, such as results.forEach((r) => console.log(r)). Even then, a one-line for...of does the job just as well. .forEach is a method you’ll mostly unlearn rather than reach for.

Your default tool is the method chain, and for...of is the fallback for the cases a chain handles poorly. Four signs tell you to reach for it.

  1. Async work that needs sequencing. .map(async ...) returns an array of promises and runs them in parallel, and .forEach(async ...) ignores them entirely. When you need them one after another, each awaiting the previous, the form is for...of with await inside the body.

  2. Early termination (break, return). Every array method outside the search/test family walks the whole array, whereas for...of can break out the moment the condition fires. Reach for it on “find the first thing that matches, then stop” patterns where the per-item work is heavy and .find doesn’t fit, because the loop also has side effects to run.

  3. Multiple statements per iteration. Three or four statements per element read well as a for...of body; the same statements crammed into a .forEach callback feel cramped and hide the control flow. If the body would carry its own local variables and intermediate calculations, for...of is the cleaner form.

  4. Need both index and value. arr.entries() yields [index, value] pairs to for...of: for (const [i, item] of arr.entries()) { ... }. Reach for it when the loop body needs both. .map((item, i) => ...) is fine when the index is just one more argument to the transform, but .entries() is clearer when the body genuinely uses both.

The first sign is the one you’ll hit in your first week of writing real code. The three options are worth seeing side by side.

await Promise.all(
invoices.map(async (i) => {
await fetch(`/api/notify/${i.id}`, { method: 'POST' });
}),
);

All requests fire concurrently, and Promise.all waits until every one has resolved. This is the reach when parallel is the goal: sending notifications, fetching many independent records, or anything where one piece of work doesn’t depend on the previous result. Deliberate fan-out gets its own full treatment in the async chapter.

So the async case comes down to two forms: when parallel is the intent, use Promise.all(arr.map(async ...)); when sequenced is the intent, use for...of with await. The .forEach(async ...) shape exists only to be flagged in code review.

Two senior habits: name the intermediate, and reach for Set

Section titled “Two senior habits: name the intermediate, and reach for Set”

Two short habits close out this surface. Neither is about a specific method; they’re what separates code that merely works from code that a teammate can read at a glance.

Two or three chained methods read clearly. Four or more often hide what’s happening. When a chain gets that long, the fix isn’t to add a comment. It’s to pull the intermediate results out into named consts, with names 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 chained methods in one fluent line. The reader has to hold the partial shape in their head at each step: what does the array look like after the first filter? after the map? after the second filter? The intent, “total overdue invoices in EUR,” is buried under the mechanics.

A four-link .filter().map().filter().reduce() broken into two or three named consts usually reads in a single pass, where the same code left as a chain takes a few read-throughs. 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. The fix is to 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));

The lookup inside the filter drops from O(m) to O(1), and the whole operation goes from O(n × m) to O(n + m). The reasoning behind Set, its semantics, the rest of its API, and where it earns its place beyond this one idiom, all land in the next lesson.

The element-by-element surface is the foundation every later unit builds on. A few forward links:

  • React list rendering. Every list in the UI is items.map((item) => <Row key={item.id} ... />). The .map is from this lesson; the key drawn from each item’s stable id is the React rule, covered in the React unit.
  • TanStack Query result shapes. The data returned from a query is the array this surface operates on, usually with optional chaining for the loading case: data?.map(...), data?.filter(...) (Unit 15).
  • Drizzle result-set transforms. select queries return T[], and massaging the rows before the response uses this lesson’s methods plus the non-mutating updates from the previous lesson (Unit 5).
  • The Set / Map surface and Map.groupBy land in the next lesson. The lazy Iterator.prototype helpers, which let you compose these methods without materializing intermediate arrays, land two lessons out.

Two .filters, two different inferred types. The first one stays as it is: .filter(Boolean) does not narrow, and seeing that for yourself in the editor is the point. Rewrite the second .filter so presentIds is string[], not (string | null)[].

Two `.filter`s, 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…

This is a teammate’s PR. The function is supposed to fetch a status for each overdue invoice, sequence the requests so the API isn’t hit in parallel, and return the count of those that need a reminder. Read the function, work out what’s wrong, and leave PR comments naming each bug. Both kinds of wrong reach from the lesson opener show up here.

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;
};