Episode 1 — Fundamentals / 1.22 — Array Methods
1.22.i — Functional Thinking with Array Methods
In one sentence: Combining non-mutating array methods like
filter,map, andreduceinto chains lets you write declarative, readable data transformations that tell the reader what happens, not how each step is implemented.
Navigation: ← 1.22.h — flat() and flatMap() · 1.22.j — Practice Problems →
1. Imperative vs declarative style
Imperative: tells the computer HOW (step by step)
const users = [
{ name: "Alice", age: 28, active: true },
{ name: "Bob", age: 17, active: true },
{ name: "Carol", age: 34, active: false },
{ name: "Dave", age: 22, active: true },
{ name: "Eve", age: 19, active: true },
];
// Get names of active adults, sorted alphabetically
const result = [];
for (let i = 0; i < users.length; i++) {
if (users[i].active && users[i].age >= 18) {
result.push(users[i].name);
}
}
result.sort();
console.log(result); // ["Alice", "Dave", "Eve"]
Declarative: tells the computer WHAT you want
const result = users
.filter(u => u.active && u.age >= 18)
.map(u => u.name)
.sort();
console.log(result); // ["Alice", "Dave", "Eve"]
The declarative version reads like a sentence: "Filter active adults, map to names, sort."
2. Method chaining — composing transformations
Method chaining works because each method returns a value (usually an array) that has the next method available on it.
const orders = [
{ product: "Laptop", price: 999, qty: 1, category: "electronics" },
{ product: "Shirt", price: 29, qty: 3, category: "clothing" },
{ product: "Phone", price: 699, qty: 1, category: "electronics" },
{ product: "Book", price: 15, qty: 5, category: "books" },
{ product: "Pants", price: 49, qty: 2, category: "clothing" },
{ product: "Headphones", price: 149, qty: 1, category: "electronics" },
];
const electronicsSummary = orders
.filter(o => o.category === "electronics") // Step 1: select electronics
.map(o => ({ ...o, total: o.price * o.qty })) // Step 2: add total field
.sort((a, b) => b.total - a.total) // Step 3: sort by total desc
.map(o => `${o.product}: $${o.total}`); // Step 4: format as strings
console.log(electronicsSummary);
// ["Laptop: $999", "Phone: $699", "Headphones: $149"]
Each line is a transformation step. Reading top to bottom tells you exactly what the pipeline does.
3. The immutability principle
Prefer non-mutating methods whenever possible:
| Non-mutating (safe) | Mutating (caution) |
|---|---|
map() | sort() |
filter() | splice() |
reduce() | reverse() |
slice() | push() / pop() |
flat() / flatMap() | shift() / unshift() |
concat() | fill() |
toSorted() (ES2023) | |
toReversed() (ES2023) | |
toSpliced() (ES2023) |
Why immutability matters
- Predictability — Functions that do not mutate inputs are easier to reason about.
- Debugging — You can inspect the original data at any point.
- React/Redux — State updates must be immutable for change detection to work.
- Concurrency — No race conditions from shared mutable state.
// DANGEROUS: mutates the input
function getSortedNames(users) {
return users.sort((a, b) => a.name.localeCompare(b.name)).map(u => u.name);
// Caller's array is now sorted! Side effect.
}
// SAFE: does not mutate
function getSortedNames(users) {
return [...users]
.sort((a, b) => a.name.localeCompare(b.name))
.map(u => u.name);
}
4. The pipeline pattern: filter, map, reduce
The most common functional pipeline follows this shape:
Raw data → filter (select) → map (transform) → reduce (aggregate)
Example: e-commerce report
const transactions = [
{ id: 1, amount: 250, type: "sale", region: "US" },
{ id: 2, amount: 150, type: "refund", region: "EU" },
{ id: 3, amount: 500, type: "sale", region: "US" },
{ id: 4, amount: 75, type: "sale", region: "EU" },
{ id: 5, amount: 300, type: "sale", region: "US" },
{ id: 6, amount: 100, type: "refund", region: "US" },
];
// Total US sales revenue
const usSalesTotal = transactions
.filter(t => t.type === "sale" && t.region === "US") // select: US sales only
.map(t => t.amount) // transform: extract amounts
.reduce((sum, amount) => sum + amount, 0); // aggregate: sum
console.log(usSalesTotal); // 1050
Example: student grade report
const students = [
{ name: "Alice", scores: [85, 92, 78] },
{ name: "Bob", scores: [45, 52, 38] },
{ name: "Carol", scores: [91, 96, 88] },
{ name: "Dave", scores: [72, 68, 75] },
];
const honorRoll = students
.map(s => ({
name: s.name,
avg: s.scores.reduce((sum, sc) => sum + sc, 0) / s.scores.length,
}))
.filter(s => s.avg >= 80)
.sort((a, b) => b.avg - a.avg)
.map(s => `${s.name} (${s.avg.toFixed(1)})`);
console.log(honorRoll);
// ["Carol (91.7)", "Alice (85.0)"]
5. When NOT to use functional methods
Performance-critical tight loops
For extremely large datasets (millions of elements) or hot code paths:
// Functional: creates 3 intermediate arrays
const result = hugeArray
.filter(x => x > 0)
.map(x => x * 2)
.reduce((sum, x) => sum + x, 0);
// Imperative: single pass, no intermediate arrays
let result2 = 0;
for (let i = 0; i < hugeArray.length; i++) {
if (hugeArray[i] > 0) {
result2 += hugeArray[i] * 2;
}
}
The imperative version does one pass and allocates zero intermediate arrays. For most applications, the functional version is fast enough. Only optimize if profiling shows it is a bottleneck.
When you need early exit
// filter visits ALL elements, even after finding the answer
const hasAdmin = users.filter(u => u.role === "admin").length > 0;
// some short-circuits at first match — more efficient
const hasAdmin2 = users.some(u => u.role === "admin");
// find also short-circuits
const admin = users.find(u => u.role === "admin");
When for...of is clearer
Sometimes a plain loop with break, continue, or await is simply more readable:
// Awkward functional approach with side effects
let found = null;
items.forEach(item => {
if (!found && item.matches(query)) found = item;
});
// Cleaner: just use for...of
for (const item of items) {
if (item.matches(query)) {
found = item;
break;
}
}
// Best: use find
const found2 = items.find(item => item.matches(query));
6. Readability balance — don't over-chain
Too much chaining (unreadable)
// What does this even do?
const result = data
.filter(d => d.active)
.map(d => ({ ...d, score: d.points / d.games }))
.filter(d => d.score > 10)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(d => d.name)
.reduce((acc, name, i) => acc + `${i + 1}. ${name}\n`, "");
Better: break into named steps
// Name your intermediate results for clarity
const activePlayers = data.filter(d => d.active);
const withScores = activePlayers.map(d => ({
...d,
score: d.points / d.games,
}));
const topPerformers = withScores
.filter(d => d.score > 10)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
const leaderboard = topPerformers
.map((d, i) => `${i + 1}. ${d.name}`)
.join("\n");
Guidelines:
- 3-4 chained methods is usually comfortable to read.
- If the chain exceeds 5-6 steps, extract intermediate variables with descriptive names.
- Each step should do one thing (filter OR map OR sort, not a complex reduce that does everything).
7. Real-world pipeline: API data processing
// Raw API response
const apiResponse = {
users: [
{ id: 1, name: "Alice Smith", email: "alice@co.com", role: "admin", lastLogin: "2024-03-15" },
{ id: 2, name: "Bob Jones", email: "bob@co.com", role: "user", lastLogin: "2024-01-02" },
{ id: 3, name: "Carol White", email: "carol@co.com", role: "user", lastLogin: "2024-03-14" },
{ id: 4, name: "Dave Brown", email: null, role: "user", lastLogin: "2023-06-10" },
{ id: 5, name: "Eve Davis", email: "eve@co.com", role: "admin", lastLogin: "2024-03-16" },
],
};
// Pipeline: get active admins with valid emails, formatted for a dropdown
const adminOptions = apiResponse.users
.filter(u => u.role === "admin") // 1. Only admins
.filter(u => u.email) // 2. Must have email
.filter(u => { // 3. Logged in within 30 days
const daysSince = (Date.now() - new Date(u.lastLogin)) / (1000 * 60 * 60 * 24);
return daysSince <= 30;
})
.map(u => ({ // 4. Shape for dropdown
value: u.id,
label: `${u.name} (${u.email})`,
}))
.sort((a, b) => a.label.localeCompare(b.label)); // 5. Sort alphabetically
console.log(adminOptions);
// [
// { value: 1, label: "Alice Smith (alice@co.com)" },
// { value: 5, label: "Eve Davis (eve@co.com)" },
// ]
8. Comparison: imperative loop vs functional chain
Problem: From an array of orders, calculate the total revenue per category for in-stock items.
Imperative
const totals = {};
for (let i = 0; i < orders.length; i++) {
const order = orders[i];
if (!order.inStock) continue;
const category = order.category;
if (!totals[category]) {
totals[category] = 0;
}
totals[category] += order.price * order.qty;
}
Functional
const totals = orders
.filter(o => o.inStock)
.reduce((acc, o) => {
acc[o.category] = (acc[o.category] || 0) + o.price * o.qty;
return acc;
}, {});
Both produce the same result. The functional version separates filtering from aggregation, making each concern independently testable and readable.
9. Composing reusable utilities
Extract common operations into named functions:
// Reusable predicates
const isActive = user => user.active;
const isAdult = user => user.age >= 18;
const isAdmin = user => user.role === "admin";
// Reusable transforms
const toDisplayName = user => `${user.firstName} ${user.lastName}`;
const toOption = user => ({ value: user.id, label: toDisplayName(user) });
// Compose them
const adminOptions = users
.filter(isActive)
.filter(isAdult)
.filter(isAdmin)
.map(toOption)
.sort((a, b) => a.label.localeCompare(b.label));
This is highly readable and each function can be unit-tested independently.
10. The reduce trap
reduce is powerful but often overused. A common anti-pattern:
// Over-engineered: doing everything in one reduce
const result = data.reduce((acc, item) => {
if (item.active) {
const transformed = { ...item, total: item.price * item.qty };
if (transformed.total > 100) {
acc.items.push(transformed);
acc.total += transformed.total;
acc.count++;
}
}
return acc;
}, { items: [], total: 0, count: 0 });
// Clearer: separate concerns
const activeItems = data
.filter(item => item.active)
.map(item => ({ ...item, total: item.price * item.qty }))
.filter(item => item.total > 100);
const result = {
items: activeItems,
total: activeItems.reduce((sum, item) => sum + item.total, 0),
count: activeItems.length,
};
The second version is more readable, more debuggable, and each step is independently testable.
11. Summary table: which method for which job
| Task | Method | Returns |
|---|---|---|
| Transform each element | map() | Array (same length) |
| Select elements by condition | filter() | Array (subset) |
| Accumulate into single value | reduce() | Any type |
| Side effect per element | forEach() | undefined |
| Check if any element passes | some() | Boolean |
| Check if all elements pass | every() | Boolean |
| Find first matching element | find() | Element or undefined |
| Find first matching index | findIndex() | Number (-1 if none) |
| Extract portion | slice() | Array (new) |
| Flatten nesting | flat() / flatMap() | Array (new) |
| Sort | toSorted() / sort() | Array |
Key takeaways
- Declarative chains (
filter→map→reduce) tell the reader what is happening. - Prefer non-mutating methods — copy before
sort, usesliceinstead ofsplice. - The classic pipeline is: filter (select) → map (transform) → reduce (aggregate).
- Do not over-chain — break into named intermediates when chains exceed 4-5 steps.
- Use imperative loops when you need early exit,
await, or maximum performance. - Extract named predicates and transforms for reusable, testable code.
Explain-It Challenge
Explain without notes:
- Why should you generally filter before mapping in a chain?
- Name three mutating array methods and their non-mutating alternatives.
- When is a
forloop better than a functional chain?
Navigation: ← 1.22.h — flat() and flatMap() · 1.22.j — Practice Problems →