Map, filter and reduce, done well
If you've been writing JavaScript for a few years, you know that map, filter, and reduce are powerful. But power without clarity is just code that works until it doesn't. I've seen production bugs trace back to a seven-level nested reduce that no one dared to touch, and I've spent time untangling a filter chain that should have been three separate steps. The good news: these three methods, used thoughtfully, make data transformations readable, maintainable, and safe. Let's get them right.
Map transforms — One input, one output per element
map is the simplest: take an array, apply a function to each element, get a new array of the same length. Every input gets exactly one output.
Picture this: you're building an e-commerce platform and need to convert product records from your database into a response shape for the API. Each product needs a calculated field, a renamed field, and some fields dropped entirely.
interface Product {
id: string;
name: string;
price_cents: number;
stock_count: number;
}
interface ProductResponse {
id: string;
title: string;
price: number;
inStock: boolean;
}
const products: Product[] = [
{ id: '1', name: 'Laptop', price_cents: 99999, stock_count: 5 },
{ id: '2', name: 'Mouse', price_cents: 2500, stock_count: 0 },
];
const response = products.map((product): ProductResponse => ({
id: product.id,
title: product.name,
price: product.price_cents / 100,
inStock: product.stock_count > 0,
}));Notice the return type annotation. When you're explicit about what map produces, TypeScript catches shape mismatches before production. The function is pure—no side effects, no mutations—and it's immediately clear what transformation each product undergoes.
One common mistake: using map for side effects. If you're calling an API or logging in the callback, you should be using forEach or a regular loop instead. map is for when you care about the resulting array.
Filter selects — Keep elements that match a predicate
filter takes a predicate function and returns a new array with only the elements for which that predicate returns true. The length of the output might be shorter, equal, or empty—but never longer than the input.
Let's say you have user records and need to find active users who haven't churned. A real-world scenario from my travel platform:
interface User {
id: string;
email: string;
status: 'active' | 'inactive' | 'suspended';
lastLoginAt: Date;
createdAt: Date;
}
const users: User[] = [
{ id: '1', email: 'alice@example.com', status: 'active', lastLoginAt: new Date('2024-01-10'), createdAt: new Date('2023-06-01') },
{ id: '2', email: 'bob@example.com', status: 'inactive', lastLoginAt: new Date('2023-01-10'), createdAt: new Date('2023-06-01') },
{ id: '3', email: 'charlie@example.com', status: 'active', lastLoginAt: new Date('2024-01-15'), createdAt: new Date('2023-06-01') },
];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const activeUsers = users.filter(
(user) => user.status === 'active' && user.lastLoginAt > thirtyDaysAgo
);The predicate is a pure function that returns a boolean. Extract it to a named function if it gets complex, which improves both readability and testability:
const isActiveAndRecent = (user: User, threshold: Date): boolean => {
return user.status === 'active' && user.lastLoginAt > threshold;
};
const activeUsers = users.filter((user) => isActiveAndRecent(user, thirtyDaysAgo));This reads like plain English: "filter users where the user is active and recent." Tests become simple too—you test isActiveAndRecent independently.
Reduce aggregates — Use it for totals and grouping, but keep it readable
reduce is the most powerful and, in my experience, the most misused. It takes an accumulator function and an initial value, threading the accumulator through each element. You can compute totals, group data, or transform structure entirely.
Let's start simple: a shopping cart total. You have items with price and quantity, and you need the sum.
interface CartItem {
productId: string;
price: number;
qty: number;
}
const items: CartItem[] = [
{ productId: 'A', price: 29.99, qty: 2 },
{ productId: 'B', price: 15.50, qty: 1 },
];
const total = items.reduce(
(sum, item) => sum + item.price * item.qty,
0
);This is clean because the accumulator has a clear meaning (running total), the operation is simple (addition), and the initial value is obvious. But this is the upper limit of what I'd write in a single reduce.
Now imagine you need to group items by category for a receipt:
interface DetailedItem {
productId: string;
category: string;
price: number;
qty: number;
}
const items: DetailedItem[] = [
{ productId: 'A', category: 'Electronics', price: 29.99, qty: 2 },
{ productId: 'B', category: 'Books', price: 15.50, qty: 1 },
{ productId: 'C', category: 'Electronics', price: 8.99, qty: 3 },
];
// Group by category
const grouped = items.reduce>(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = [];
}
acc[item.category].push(item);
return acc;
},
{}
);This works, but it's already harder to follow. The accumulator is an object, we're mutating it, and the logic involves conditionals. I'm still okay with it because the intent is clear. But if you need to both group and calculate subtotals, or add filtering logic, here's where I'd break it into steps:
// Step 1: Filter to items we actually want to process
const validItems = items.filter((item) => item.qty > 0);
// Step 2: Group by category
const grouped = validItems.reduce>(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = [];
}
acc[item.category].push(item);
return acc;
},
{}
);
// Step 3: Calculate subtotal per category
const subtotals = Object.entries(grouped).map(([category, items]) => ({
category,
subtotal: items.reduce((sum, item) => sum + item.price * item.qty, 0),
}));Three clear steps beat one clever reduce. Each step has a single responsibility, and when a bug appears (and it will), you know exactly where to look.
Chain thoughtfully — Several clear steps beat one clever reduce
Here's the philosophy: if you're tempted to nest logic or chain multiple operations inside a single reduce, split it into separate map, filter, and reduce calls instead. Modern JavaScript engines are fast enough that this doesn't hurt performance, and readability is worth far more than a micro-optimization.
Let's look at a real scenario from my CRM platform. You have customer records, and you need the average deal value for customers in the "enterprise" segment who joined in the last year:
interface Customer {
id: string;
segment: 'startup' | 'mid-market' | 'enterprise';
joinedAt: Date;
deals: Array<{ value: number }>;
}
const customers: Customer[] = [
{
id: '1',
segment: 'enterprise',
joinedAt: new Date('2023-06-01'),
deals: [{ value: 50000 }, { value: 75000 }],
},
{
id: '2',
segment: 'mid-market',
joinedAt: new Date('2024-01-01'),
deals: [{ value: 10000 }],
},
{
id: '3',
segment: 'enterprise',
joinedAt: new Date('2024-06-01'),
deals: [{ value: 120000 }],
},
];
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
// Step 1: Filter to enterprise customers who joined recently
const recentEnterpriseCustomers = customers.filter(
(c) => c.segment === 'enterprise' && c.joinedAt > oneYearAgo
);
// Step 2: Flatten deals and map to values
const dealValues = recentEnterpriseCustomers
.flatMap((customer) => customer.deals)
.map((deal) => deal.value);
// Step 3: Calculate average
const averageDealValue = dealValues.length > 0
? dealValues.reduce((sum, val) => sum + val, 0) / dealValues.length
: 0;Each step answers one question: "Which customers qualify?", "What are their deal values?", and "What's the average?" If performance becomes a concern, you can refactor to a single pass, but that's a documented optimization, not a premature guess.
Common mistakes
- Using map for side effects: If you're calling an API or writing to a database inside a map callback, use forEach or a for loop instead. Readers expect map to produce a new array, not side effects.
- Mutating inside reduce: If you're calling push or assigning to object properties within your reduce, make sure it's intentional and document why. Otherwise, extract it to a separate step.
- Nested reduce for complex transformations: If your reduce callback itself contains another reduce, or if it's more than five lines, break it down. Readability is a feature, not a luxury.
- Forgetting the initial value in reduce: If you don't provide an initial value and your array is empty, reduce throws an error. Always be explicit:
reduce((acc, item) => ..., {})orreduce((acc, item) => ..., 0). - Chaining without intermediate variables:
items.filter(...).map(...).reduce(...)on a single line is hard to debug. Give each step a variable name, even if it's temporary.
Key takeaways
- Use
mapto transform arrays element-by-element. Always use a return type annotation to catch shape mismatches early. - Use
filterwith named, testable predicate functions. Pure functions with boolean returns are easier to reason about. - Use
reducefor aggregation, but break complex transformations into multiple steps. Readable code beats clever code. - Chain methods thoughtfully: filter → map → reduce, not everything in one reduce.
- Extract predicates and accumulator logic to named functions. This improves testability and makes intent clear to the next person (often yourself, three months later).
- Type your accumulators explicitly in reduce. TypeScript's generic syntax—
reduce<T>(...)—is your friend.






Comments
Loading comments…