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.
Eight methods, four output shapes
Section titled “Eight methods, four output shapes”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.
Transform: .map and .flatMap
Section titled “Transform: .map and .flatMap”.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.
Subset: .filter and the type predicate
Section titled “Subset: .filter and the type predicate”.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.
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.
Fold: .reduce, and when not to
Section titled “Fold: .reduce, and when not to”.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.
const byId = Object.fromEntries(invoices.map((i) => [i.id, i]));Object.fromEntries plus .map is the linear-time form. One walk builds the [key, value] pairs, and one walk inside Object.fromEntries drops them into the object. There’s no accumulator copying, so no quadratic blow-up. When the key is a non-string, or you need .get / .has semantics, reach for a Map instead; that surface lands in the next lesson.
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.
Search and test: short-circuit by design
Section titled “Search and test: short-circuit by design”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 is almost never the right reach
Section titled “.forEach is almost never the right reach”.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 siteThe 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.
When to drop into for...of
Section titled “When to drop into for...of”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.
-
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 isfor...ofwithawaitinside the body. -
Early termination (
break,return). Every array method outside the search/test family walks the whole array, whereasfor...ofcanbreakout 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.finddoesn’t fit, because the loop also has side effects to run. -
Multiple statements per iteration. Three or four statements per element read well as a
for...ofbody; the same statements crammed into a.forEachcallback feel cramped and hide the control flow. If the body would carry its own local variables and intermediate calculations,for...ofis the cleaner form. -
Need both index and value.
arr.entries()yields[index, value]pairs tofor...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.
for (const i of invoices) { await fetch(`/api/notify/${i.id}`, { method: 'POST' });}Each request waits for the previous one to land before starting. Reach for it when you need to throttle a downstream API, when each call depends on the previous one, or when ordering matters. .forEach(async ...) is the broken third variant: it fires in parallel and the outer code doesn’t await any of the requests, which is almost never what anyone meant.
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.
Name the intermediate
Section titled “Name the intermediate”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.
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 operation, with three named bindings. Each line reads as one step of intent, and the names overdueInvoices and overdueEurInvoices carry the meaning the chain hid. The dead .map((i) => ({ ...i, currency: ... })) from tab 1 also disappears: the currency was a derived value used only once, so the second filter can compute it inline. Naming the intermediate is often the real fix, not reworking the chain.
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.
Where this lands later
Section titled “Where this lands later”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.mapis from this lesson; thekeydrawn from each item’s stable id is the React rule, covered in the React unit. - TanStack Query result shapes. The
datareturned 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.
selectqueries returnT[], 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/Mapsurface andMap.groupByland in the next lesson. The lazyIterator.prototypehelpers, which let you compose these methods without materializing intermediate arrays, land two lessons out.
Narrow the array with a type predicate
Section titled “Narrow the array with a type predicate”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[]
Refactor a tangled chain
Section titled “Refactor a tangled chain”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.
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 async callback returns. All the fetches fire in parallel, the .forEach call returns undefined synchronously, and processOverdue resolves with count === 0 before any of the requests have landed. The instructions also asked for sequenced work — one request at a time — which .forEach couldn’t deliver even if it did await. The senior fix is a for...of loop with await inside the body:
for (const i of overdueInvoices) { const res = await fetch(`/api/customers/${i.customerId}/status`); const { needsReminder } = await res.json(); if (needsReminder) count += 1;}Two things on this chain. The .map((i) => ({ ...i, key: i.id })) step adds a key field that nothing downstream reads — it’s dead weight that also forces the next .filter to walk over freshly spread objects for no reason. And the four-link .filter().map().filter().forEach() shape hides the intent; once you drop the dead .map, naming the intermediate reads in one pass:
const overdueInvoices = invoices.filter( (i) => i.status === 'overdue' && i.amountCents > 0,);The pattern to spot is the combination — a fluent chain that ends in an async .forEach. Both halves of the bug come from the same instinct (“I know .map / .filter / .forEach, so I’ll use them for everything”). The senior reflex is to recognize when for...of is the right tool for sequencing and when naming the intermediate is the right tool for readability. The .forEach(async ...) shape is the more dangerous of the two — it ships looking 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.