JavaScript Arrays and Objects: map/filter/reduce, Destructuring, Spread and Common Mistakes
Key takeaways
JavaScript arrays and objects: map, filter, reduce, sorting, Object.keys/entries, destructuring, spread/rest—patterns for everyday JS code.
Introduction
Arrays and objects are the data structures you will use most often in JavaScript. The distinction between them isn’t arbitrary: arrays model an ordered sequence (position matters, and most of their methods — map, filter, reduce, sort — care about that order), while objects model a collection of named properties (position is meaningless; a property is looked up by key, not by where it happens to sit). Reaching for the right one up front avoids a lot of awkward code later — modeling a genuinely ordered list as an object (numeric-looking string keys) or an unordered lookup table as an array (linear-scanning it for a match) both work, technically, but fight the tool.
Arrays
Creating and indexing
new Array(5) is the one constructor call in this block that behaves differently from what it looks like: it doesn’t create an array of five undefined values you can iterate over with map/forEach — it creates an array with length: 5 but no actual elements (a “sparse” array), and methods like map skip holes entirely, silently producing an equally sparse result. This is a common source of confusion for anyone expecting new Array(5).map(() => 0) to produce five zeros; it produces another sparse array of length 5. Array.from({ length: 5 }, () => 0) or Array(5).fill(0) are the idiomatic ways to actually get five real elements.
let fruits = ["apple", "banana", "cherry"];
let numbers = [1, 2, 3, 4, 5];
let mixed = [1, "hello", true, null, { name: "Alice" }];
let empty1 = [];
let empty2 = new Array();
let arr = new Array(5);
console.log(arr.length);
console.log(fruits[0]);
console.log(fruits[fruits.length - 1]);
fruits[1] = "grape";
Add / remove
let arr = [1, 2, 3];
arr.push(4);
let last = arr.pop();
arr.unshift(0);
let first = arr.shift();
arr.splice(1, 1);
arr.splice(1, 0, 2);
arr.splice(1, 1, 10, 20);
push/pop operate on the end of the array and are O(1) — the engine just adjusts the length and writes/reads the last slot. unshift/shift operate on the beginning, which means every other element has to be reindexed and shifted over by one position, making them O(n) — on a large array in a hot loop, repeatedly calling unshift can be noticeably slower than the equivalent push pattern (building the array in reverse order, then reverse() once at the end, or just always preferring push/pop when order permits). splice is the most flexible of the four but also mutates in place and returns the removed elements, not the modified array — a frequent bug is assuming splice’s return value is the updated array when it’s actually the removed slice.
Search
let numbers = [1, 2, 3, 4, 5, 3];
console.log(numbers.indexOf(3));
console.log(numbers.lastIndexOf(3));
console.log(numbers.includes(3));
let found = numbers.find(x => x > 3);
let index = numbers.findIndex(x => x > 3);
console.log(numbers.some(x => x > 4));
console.log(numbers.every(x => x > 0));
indexOf and includes both do a strict-equality (===) linear scan, which is why neither can find an object by its contents — [{id: 1}].includes({id: 1}) is false, because it’s comparing object references, not structural equality. find/findIndex exist specifically to search by a condition rather than an exact value, which is what makes them the right tool for “the first object whose id matches” instead of trying to match an entire object by reference. some/every short-circuit (stop scanning) as soon as the answer is determined — some returns true the moment it finds one match, every returns false the moment it finds one mismatch — so on a large array they can be meaningfully cheaper than filter(...).length > 0, which always scans the whole array first.
Transform: map, filter, reduce
let numbers = [1, 2, 3, 4, 5];
let doubled = numbers.map(x => x * 2);
let evens = numbers.filter(x => x % 2 === 0);
let sum = numbers.reduce((acc, x) => acc + x, 0);
let words = ["apple", "banana", "cherry"];
let wordLengths = words.reduce((acc, word) => {
acc[word] = word.length;
return acc;
}, {});
let nested = [[1, 2], [3, 4], [5]];
let flattened = nested.reduce((acc, a) => acc.concat(a), []);
These three are the backbone of functional-style array processing in JS, and each answers a different question: map asks “what does each element become,” filter asks “which elements stay,” and reduce asks “what single value does the whole array collapse into.” reduce’s second argument (the initial accumulator, 0 or {} above) isn’t optional in practice — omitting it makes reduce use the array’s first element as the starting accumulator and start iterating from the second element instead, which silently changes behavior (and throws on an empty array) in ways that are easy to miss in code review.
Chaining:
let result = numbers
.filter(x => x > 2)
.map(x => x * 2)
.reduce((a, b) => a + b, 0);
Chaining reads left-to-right as a pipeline, but it’s worth knowing each step allocates a brand-new intermediate array — filter produces one array, map produces another from that, before reduce finally collapses it. For typical UI-scale data this cost is invisible, but on very large arrays processed in a hot path, a single combined loop (or reduce doing the filtering and mapping itself) avoids the extra intermediate allocations — a tradeoff of readability against a small amount of throughput that’s rarely worth making until profiling actually points at it.
Mutating vs non-mutating:
- Mutates:
push,pop,shift,unshift,splice,sort,reverse - Does not mutate:
map,filter,reduce,slice,concat
This split matters most when the same array is referenced from more than one place — a React component holding array state in a variable, a function that received the array as an argument and isn’t expected to have side effects on it, or simply code elsewhere in the program that still expects the original order. Calling .sort() directly on a shared array silently reorders it for every other piece of code holding that same reference; [...arr].sort() (or arr.slice().sort() before spread syntax existed) makes the intent to sort a copy explicit instead of relying on every caller remembering not to mutate shared data.
Sorting
let numbers = [3, 1, 4, 1, 5, 9, 2];
numbers.sort();
numbers.sort((a, b) => b - a);
let words = ["banana", "apple", "cherry"];
words.sort();
let users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Carol", age: 20 }
];
users.sort((a, b) => a.age - b.age);
numbers.reverse();
Plain numbers.sort() (no comparator) is the single most common array bug in JavaScript: sort without an argument converts every element to a string and sorts lexicographically, so [3, 1, 4, 1, 5, 9, 2] (or worse, [1, 10, 2, 20, 3], covered again in the “Common mistakes” section below) doesn’t sort numerically at all — "10" sorts before "2" because "1" < "2" as characters. A comparator function ((a, b) => a - b) is required any time you’re sorting numbers, and the same idea generalizes to (a, b) => a.age - b.age for sorting objects by a numeric field — the comparator just needs to return negative/zero/positive to indicate ordering.
More helpers
let arr = [1, 2, 3, 4, 5];
let sub = arr.slice(1, 4);
let combined = [1, 2].concat([3, 4]);
let joined = arr.join(", ");
let nested = [1, [2, 3], [4, [5, 6]]];
nested.flat();
nested.flat(2);
nested.flat(Infinity);
let words = ["hello world", "foo bar"];
words.flatMap(word => word.split(" "));
flatMap is worth knowing on its own rather than as just “map then flat(1)”: it’s specifically the tool for when a mapping function can produce zero or more results per input element — splitting each sentence into words (one input becomes several outputs) or filtering-while-mapping by returning [] for elements you want to drop. It’s a single pass rather than allocating one intermediate array for map and another for flat, which is a small but real efficiency win when it fits the shape of the problem.
Objects
Literals and property access
Dot notation (person.name) and bracket notation (person["name"] or person[key]) access the exact same property, but only bracket notation accepts a variable or a computed expression as the key — dot notation requires a literal identifier known at write time. This is why dynamic property access (looking up a field whose name comes from user input, a loop variable, or a config value) always has to use brackets; it’s also why person[age] — using a bare age — is a common typo: without quotes, age is read as a variable name to look up (and throws ReferenceError if no such variable exists), not the string "age".
let person = {
name: "Alice",
age: 25,
city: "Seoul"
};
console.log(person.name);
console.log(person["age"]);
let key = "city";
console.log(person[key]);
person.job = "developer";
person.age = 26;
delete person.city;
Methods
let person = {
name: "Alice",
age: 25,
greet: function() {
console.log(`Hello, ${this.name}.`);
},
introduce() {
console.log(`${this.age} years old — ${this.name}.`);
}
};
The shorthand method syntax (introduce() { ... }) and the older greet: function() { ... } form both create ordinary functions attached to the object, but they behave identically with respect to this — both bind this to whatever object the method is called on (person.greet() binds this to person), which is different from an arrow function property (greet: () => {...}), where this would instead capture the surrounding scope at the time the object literal was created, not the object itself. This distinction is one of the most common sources of confusion once callbacks and event handlers enter the picture, and it’s why object methods are conventionally written with function/shorthand syntax rather than arrow functions.
Static Object methods
let person = { name: "Alice", age: 25, city: "Seoul" };
Object.keys(person);
Object.values(person);
Object.entries(person);
for (let [key, value] of Object.entries(person)) {
console.log(`${key}: ${value}`);
}
let defaults = { theme: "dark", lang: "ko" };
let userSettings = { lang: "en" };
let settings = Object.assign({}, defaults, userSettings);
let frozen = Object.freeze({ x: 10 });
let sealed = Object.seal({ x: 10 });
Object.assign({}, defaults, userSettings) and object spread ({ ...defaults, ...userSettings }) both merge left-to-right, with later sources overwriting earlier keys — but both only do a shallow merge. If defaults.theme were itself an object (nested settings), userSettings.theme would fully replace it rather than merging its inner keys, which surprises people expecting a deep merge. Object.freeze prevents adding, removing, or reassigning top-level properties (but, again, doesn’t freeze nested objects — a frozen object can still have its nested object’s properties mutated); Object.seal is the weaker sibling that still allows changing existing property values, just not adding or deleting keys.
Destructuring
Arrays
let arr = [1, 2, 3, 4, 5];
let [a, b] = arr;
let [first, ...rest] = arr;
let [x, , z] = arr;
let [m, n] = [10, 20];
[m, n] = [n, m];
Array destructuring pulls by position, not by name, which is exactly what makes [m, n] = [n, m] a clean one-line swap — the right side builds a temporary array [n, m] first, then destructures it back into m and n in the new order, avoiding the classic three-line swap with a manual temp variable. The [x, , z] skip-with-empty-slot syntax works the same way: a comma with nothing between it just advances the position without binding a variable, useful when you want the first and third elements of a fixed-shape array and don’t care about the second.
Objects
let person = { name: "Alice", age: 25, city: "Seoul" };
let { name, age } = person;
let { name: userName, age: userAge } = person;
let { name, age, job = "n/a" } = person;
let { name, ...rest } = person;
let user = {
id: 1,
profile: { name: "Alice", age: 25 }
};
let { profile: { name, age } } = user;
Object destructuring, unlike array destructuring, pulls by name rather than position — { name: userName } reads as “find the name property, but bind it locally to userName,” which is the standard way to rename a property on the way in, most commonly to avoid a naming collision with something already in scope. The default-value form ({ name, age, job = "n/a" }) only kicks in when the property is undefined — either genuinely missing from the object, or explicitly set to undefined — not when it’s any other falsy value like 0, "", or null; destructuring { count: 0 } with a default of count = 10 still gives you 0, which is the correct but sometimes-surprising behavior.
Function parameters
function printUser({ name, age, city = "Seoul" }) {
console.log(`${name} (${age}, ${city})`);
}
function getMinMax([first, ...rest]) {
return {
min: Math.min(first, ...rest),
max: Math.max(first, ...rest)
};
}
Destructuring directly in a parameter list (function printUser({ name, age, city = "Seoul" })) is a very common pattern for functions that take a single “options object” instead of several positional arguments — it documents the expected shape right at the call site and lets each field have its own default, which is far more readable than a long list of positional parameters where callers have to remember the order. It has one sharp edge worth knowing: if the function is called with no argument at all (printUser()), destructuring a parameter that’s undefined throws TypeError: Cannot destructure property 'name' of 'undefined' — giving the whole parameter itself a default (function printUser({ name, age, city = "Seoul" } = {})) is how you guard against that.
Spread and rest
Spread
let combined = [...[1, 2, 3], ...[4, 5, 6]];
let original = [1, 2, 3];
let copy = [...original];
function add(a, b, c) {
return a + b + c;
}
add(...[1, 2, 3]);
let merged = { ...{ a: 1 }, ...{ b: 2 } };
let person = { name: "Alice", age: 25 };
let personCopy = { ...person };
let defaults = { theme: "dark", lang: "ko" };
let userSettings = { ...defaults, lang: "en" };
Spread and the array/object methods covered earlier (slice, Object.assign) solve overlapping problems, but spread is generally preferred in modern code for its readability — { ...person } reads as “copy of person” at a glance, where Object.assign({}, person) requires knowing the empty-object-as-target convention. The one thing spread can’t do that concat/Object.assign can is merge into an existing object or array without creating a new one — spread always produces a new array or object, which is exactly the immutable-update pattern React and Redux rely on for detecting state changes, but means it allocates on every call rather than mutating in place.
Rest
function sum(...numbers) {
return numbers.reduce((acc, x) => acc + x, 0);
}
let [first, second, ...rest] = [1, 2, 3, 4, 5];
let { name, ...otherInfo } = { name: "Alice", age: 25, city: "Seoul" };
The ... syntax means opposite things depending on which side of an assignment it’s on, which is worth stating explicitly since it’s easy to conflate: in a function call or array/object literal (add(...[1,2,3]), {...defaults}), it spreads an existing collection out into individual values. In a destructuring pattern or a function’s parameter list (function sum(...numbers), let [first, ...rest] = arr), it does the reverse — it gathers the remaining values back into a new array or object. Same three dots, opposite direction, and which one applies is determined entirely by the syntactic position.
Advanced array usage
forEach vs map
let numbers = [1, 2, 3];
numbers.forEach(x => console.log(x * 2));
let doubled = numbers.map(x => x * 2);
The rule of thumb is: use map when you need the transformed array itself (to render it, pass it on, store it), and use forEach when you’re only executing side effects per element and have no use for a return value — forEach always returns undefined, so chaining anything after it or trying to use its “result” is a mistake linters will typically flag. Using map purely for side effects (throwing away the returned array) works but signals the wrong intent to a reader, and wastes the allocation of an array nobody uses.
reduce patterns
let words = ["apple", "banana", "apple", "cherry", "banana", "apple"];
let frequency = words.reduce((acc, word) => {
acc[word] = (acc[word] || 0) + 1;
return acc;
}, {});
let users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];
let userMap = users.reduce((acc, user) => {
acc[user.id] = user;
return acc;
}, {});
let students = [
{ name: "Alice", grade: "A" },
{ name: "Bob", grade: "B" },
{ name: "Carol", grade: "A" }
];
let grouped = students.reduce((acc, s) => {
if (!acc[s.grade]) acc[s.grade] = [];
acc[s.grade].push(s.name);
return acc;
}, {});
These three patterns — counting frequency, building a lookup map by ID, and grouping into buckets by a shared field — cover the large majority of real reduce usage, and they share the same shape for a reason: each one turns an array (good for iteration) into an object (good for O(1) lookup by key), which is a genuinely useful transformation whenever code downstream needs to repeatedly look something up by ID rather than re-scanning the array with find every time. Building userMap once and reusing it is meaningfully faster than calling users.find(u => u.id === id) inside a loop, since the former is O(1) per lookup after the one-time O(n) build, while the latter is O(n) per lookup.
Chaining example
let users = [
{ name: "Alice", age: 25, active: true },
{ name: "Bob", age: 17, active: false },
{ name: "Carol", age: 30, active: true }
];
let activeAdultNames = users
.filter(u => u.active && u.age >= 18)
.map(u => u.name)
.sort();
Note the order here: filter runs before map, which matters for performance as much as correctness — filtering down to the relevant subset first means map (and any later steps) only ever touches the elements that survived, instead of transforming every element and then throwing most of the results away. On small arrays this is negligible, but it’s a habit worth having by default: put the cheapest, most-narrowing operation first in a chain.
Practical examples
Array utilities
function unique(arr) {
return [...new Set(arr)];
}
// Set stores only unique values and preserves insertion order, which is
// what makes spreading it back into an array the idiomatic dedup pattern —
// but like includes()/indexOf(), Set compares by reference for objects, so
// this only dedupes primitives (numbers, strings) correctly, not objects
// that are structurally equal but different references.
function chunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
function difference(arr1, arr2) {
return arr1.filter(x => !arr2.includes(x));
}
function intersection(arr1, arr2) {
return arr1.filter(x => arr2.includes(x));
}
Both difference and intersection scan arr2 with .includes() once per element of arr1, which is O(n × m) overall — fine for small arrays, but worth converting arr2 into a Set first (const set2 = new Set(arr2), then set2.has(x)) on large inputs, since Set.has is O(1) versus Array.includes’s O(m) linear scan, turning the whole operation into O(n + m).
Object helpers
function deepClone(obj) {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(deepClone);
const cloned = {};
for (let key in obj) {
cloned[key] = deepClone(obj[key]);
}
return cloned;
}
function filterObject(obj, predicate) {
return Object.keys(obj)
.filter(key => predicate(obj[key], key))
.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {});
}
deepClone here is a hand-rolled version of what structuredClone() (a built-in global in modern JS runtimes) does natively — worth knowing before reaching for a manual implementation, since structuredClone also correctly handles Date, Map, Set, and circular references, none of which this recursive version does (it would infinite-loop on a self-referencing object). filterObject is a genuinely useful pattern absent from the standard library — there’s no built-in Object.filter, so combining Object.keys with filter and reduce this way is the idiomatic workaround.
CSV helpers
function parseCSV(csv) {
const lines = csv.trim().split("\n");
const headers = lines[0].split(",");
return lines.slice(1).map(line => {
const values = line.split(",");
return headers.reduce((obj, header, i) => {
obj[header] = values[i];
return obj;
}, {});
});
}
function toCSV(arr) {
if (arr.length === 0) return "";
const headers = Object.keys(arr[0]);
const headerRow = headers.join(",");
const dataRows = arr.map(obj =>
headers.map(h => obj[h]).join(",")
);
return [headerRow, ...dataRows].join("\n");
}
These CSV helpers are intentionally minimal and worth treating as a learning example rather than production code: they don’t handle a value that itself contains a comma or a quoted field spanning a literal newline, both of which are legal in real CSV per the format’s spec and both of which split(",")/split("\n") will mis-parse. For anything reading CSV from an untrusted or external source, a proper parser library (which handles quoting/escaping correctly) is worth the dependency.
Common mistakes
Copying arrays
let arr1 = [1, 2, 3];
let arr2 = arr1;
arr2[0] = 100;
let arr3 = [...arr1];
let nested = [[1, 2], [3, 4]];
let copy = [...nested];
copy[0][0] = 999;
let deepCopy = JSON.parse(JSON.stringify(nested));
arr2 = arr1 doesn’t copy anything — both variables end up pointing at the exact same array in memory, so mutating one mutates the other, which is the single most common “why did my other variable change too” bug for anyone coming from a language with value-semantics arrays. Spread ([...arr1]) fixes that but only one level deep: copy[0] is a fresh top-level slot, but copy[0][0] still points at the same nested array as nested[0][0], so mutating a nested element leaks through a shallow copy exactly like the plain-reference case did. JSON.parse(JSON.stringify(...)) does a genuine deep copy, but it’s a lossy round-trip through JSON, which silently drops anything JSON can’t represent — functions, undefined values, Date objects (they survive as strings, not Date instances), and it also throws on circular references; structuredClone(), mentioned above, is the more correct modern alternative for deep copying.
Numeric sort
let numbers = [1, 10, 2, 20, 3];
numbers.sort();
numbers.sort((a, b) => a - b);
This is the same lexicographic-vs-numeric gotcha covered under Sorting above, shown again here because it’s specifically worth memorizing as one of the most-reported “JavaScript is broken” bugs by newcomers — it isn’t a bug, it’s sort’s documented default behavior, and (a, b) => a - b is the fix every time you’re sorting numbers.
Object identity
let obj1 = { a: 1 };
let obj2 = { a: 1 };
console.log(obj1 === obj2); // false
function deepEqual(obj1, obj2) {
if (obj1 === obj2) return true;
if (typeof obj1 !== "object" || typeof obj2 !== "object" ||
obj1 === null || obj2 === null) return false;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (let key of keys1) {
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) return false;
}
return true;
}
=== on objects compares identity (are these literally the same object in memory), never structure — this is true for arrays too, and it’s why obj1 === obj2 is false even though the two objects look identical. This trips people up constantly in React and similar UI code that relies on === to decide whether to re-render, which is why libraries like Redux and React emphasize replacing objects wholesale (via spread) rather than mutating them in place: a mutated object still === its old self, so change-detection code that only checks reference equality would miss the update entirely. deepEqual’s recursive structural comparison here is the manual version of what libraries like Lodash’s isEqual provide, and it’s worth reaching for the library version in real code — this hand-written one, for instance, doesn’t handle arrays, Date, or NaN correctly.
Exercises
Flatten
function flatten(arr) {
return arr.reduce((acc, item) =>
acc.concat(Array.isArray(item) ? flatten(item) : item), []);
}
groupBy
function groupBy(arr, key) {
return arr.reduce((acc, obj) => {
const g = obj[key];
if (!acc[g]) acc[g] = [];
acc[g].push(obj);
return acc;
}, {});
}
Rotate
function rotateArray(arr, k) {
k = k % arr.length;
return [...arr.slice(-k), ...arr.slice(0, -k)];
}
Next in the series
Related Articles
- Get started with JavaScript
- JavaScript Functions | Declarations, Arrow Functions, Callbacks, Closures
- JavaScript Async Programming | Promises, async/await