Arrays and objects: shaping, copying and counting data
The idea
A single labeled box (a variable) only gets you so far. Most real data comes in groups - a whole shelf of books, not one - or in a single item with several labeled parts, like a book's title, author and price all living together. This lesson covers both pictures: a row of lockers for a list of items, each one found by its position, and a mailroom for a single record, where each piece of information is found by its name instead of a number.
Every example below works with the catalog of a small shop, Lantern Street Books. Keep using your practice folder and run files with node, the way you have been.
Array methods: map, filter, reduce, find and sort
Picture the shop's catalog as a row of lockers, one book per locker, in order. An array method is a worker who walks down that row and does one specific job, so you don't have to write the walk yourself every time.
const catalog = [
{ title: "The Salt Path", author: "Rowan Hale", price: 14, inStock: true, genre: "memoir" },
{ title: "Nightjar", author: "Priya Osei", price: 9, inStock: false, genre: "poetry" },
{ title: "The Long Coast", author: "Rowan Hale", price: 18, inStock: true, genre: "memoir" },
{ title: "Small Fires", author: "Mina Caldwell", price: 11, inStock: true, genre: "poetry" },
];
.map() opens every locker, makes one new thing from what's inside, and lines the results up in a brand-new row - the original row is untouched:
const priceTags = catalog.map((book) => `${book.title}: $${book.price}`);
console.log(priceTags);
[
'The Salt Path: $14',
'Nightjar: $9',
'The Long Coast: $18',
'Small Fires: $11'
]
.filter() walks the row and keeps only the lockers where a test comes back true, again building a new row:
const inStockBooks = catalog.filter((book) => book.inStock);
console.log(inStockBooks.length);
3
.find() walks the row and stops at the very first locker that passes the test, handing back that one item (not a row) - or undefined if nobody matches:
const nightjar = catalog.find((book) => book.title === "Nightjar");
console.log(nightjar.author);
Priya Osei
.reduce() is the worker who doesn't build a new row at all - it walks the lockers combining everything into one single answer. You give it a starting value and a rule for combining the running answer with each item:
const totalInStockValue = catalog.reduce((runningTotal, book) => {
return book.inStock ? runningTotal + book.price : runningTotal;
}, 0);
console.log(totalInStockValue);
43
0 is where the running total starts, and each book either adds its price or doesn't. .sort() reorders the row itself, using a comparator function that tells it which of two items should come first:
catalog.sort((a, b) => a.price - b.price);
console.log(catalog.map((book) => book.title));
[ 'Nightjar', 'Small Fires', 'The Salt Path', 'The Long Coast' ]
The comparator returns a negative number when a belongs before b, positive when b belongs first, and 0 when it doesn't matter. Unlike the others, .sort() changes the original row in place instead of making a new one - worth remembering, because it means catalog itself is now in price order. You'll see a safer way to sort a copy later in this lesson.
Objects, optional chaining and nullish coalescing
An object is a mailroom instead of a row of lockers: you find what you want by its name, not its position. Each named slot is called a property.
const book = {
title: "Nightjar",
author: "Priya Osei",
price: 9,
reviews: { count: 12, average: 4.6 },
};
console.log(book.title);
console.log(book.reviews.average);
Nightjar
4.6
book.title is dot notation: walk into the mailroom, find the slot named title, read what's in it. reviews holds another small mailroom inside this one, so book.reviews.average just walks in twice. But not every book has reviews yet:
const newArrival = { title: "Small Fires", author: "Mina Caldwell" };
console.log(newArrival.reviews.average);
TypeError: Cannot read properties of undefined (reading 'average')
newArrival.reviews doesn't exist, so it's undefined, and asking undefined for an average slot crashes the whole program. Optional chaining, written ?., checks first: if the thing on the left is missing, the whole chain gives back undefined instead of crashing.
console.log(newArrival.reviews?.average);
console.log(book.reviews?.average);
undefined
4.6
The first line is newArrival (no reviews, so the chain stops safely at undefined); the second is book, which does have reviews, so the chain reaches all the way through to 4.6. undefined is a safe result, but it's not always a useful one to show someone. Nullish coalescing, written ??, supplies a fallback, but only when the value on its left is exactly null or undefined:
console.log(newArrival.reviews?.average ?? "no reviews yet");
const quietBook = { title: "Low Tide", reviews: { count: 0, average: 0 } };
console.log(quietBook.reviews?.average ?? "no reviews yet");
no reviews yet
0
The second line matters: quietBook's average really is 0, and ?? only replaces null or undefined - a real 0 sails through untouched. That's different from treating every falsy value (0, "", false) as "missing", which you learned about back when you met truthy and falsy values.
Destructuring and spread
Destructuring unpacks a mailroom's slots (or a locker row's positions) straight into their own named boxes, instead of writing book.title, book.author over and over:
const { title, author } = book;
console.log(title, author);
const [first, second] = catalog;
console.log(first.title, second.title);
Nightjar Priya Osei
Nightjar Small Fires
Object destructuring matches by name ({ title, author } pulls those exact slots); array destructuring matches by position, same as a locker row. You can rename a slot on the way out and give it a fallback, the same way a function parameter gets a default:
const { title: bookTitle, reviews: { average = 0 } = {} } = newArrival;
console.log(bookTitle, average);
Small Fires 0
reviews: { average = 0 } = {} means: if newArrival.reviews is missing, pretend it's an empty mailroom {}, and if average is missing from it, use 0.
Spread, written ..., is the opposite move: instead of unpacking one mailroom, it pours every slot from one or more mailrooms into a brand-new one.
const discountedBook = { ...book, price: 7 };
console.log(discountedBook.title, discountedBook.price, book.price);
Nightjar 7 9
discountedBook starts as a full copy of every slot in book (including the price: 9 from when book was created above), and then price: 7 overwrites just that one slot in the new object. book itself is untouched - its price is still 9. Spread works on rows too, building a new one:
const newShelf = [...catalog, { title: "Winter Light", author: "Guest Writer", price: 15 }];
console.log(newShelf.length, catalog.length);
5 4
Map and Set
A plain object is a mailroom built for names you already know going in. A Map is built for looking things up by any key - a whole book object, a number, a string - and, unlike a plain object, you can walk through everything it holds in the order you put it in.
const priceByTitle = new Map();
priceByTitle.set("Nightjar", 9);
priceByTitle.set("Small Fires", 11);
console.log(priceByTitle.get("Nightjar"));
console.log(priceByTitle.has("The Long Coast"));
console.log(priceByTitle.size);
for (const [title, price] of priceByTitle) {
console.log(`${title} costs $${price}`);
}
9
false
2
Nightjar costs $9
Small Fires costs $11
.set(key, value) stores a pair, .get(key) reads it back, .has(key) checks without reading, and .size tells you how many pairs are stored - no counting required. A Map is iterable with for...of, giving you a [key, value] pair each time, just like the array destructuring you met above.
A Set is simpler: it only remembers whether something is in it, and it never stores the same value twice.
const genres = new Set();
genres.add("memoir");
genres.add("poetry");
genres.add("memoir");
console.log(genres.size);
console.log(genres.has("poetry"));
2
true
Adding "memoir" a second time changes nothing - a Set quietly ignores a value it already has. That makes it the natural tool whenever you need "every different X that showed up", without writing the duplicate-checking yourself.
Copying and immutability with structuredClone
Spread looks like it makes a totally separate copy, but it only copies the outer mailroom - any slot that holds another mailroom (or a row) is shared between the original and the copy, not duplicated. This is called a shallow copy:
const original = { title: "Nightjar", reviews: { count: 12, average: 4.6 } };
const shallowCopy = { ...original };
shallowCopy.reviews.average = 5;
console.log(original.reviews.average);
5
That's surprising the first time: changing shallowCopy.reviews.average also changed original.reviews.average, because shallowCopy.reviews and original.reviews are the exact same inner mailroom - spread only copied the outer slots, and reviews held an address to that inner mailroom, not the mailroom itself. Immutability means treating data as something you never change in place, only replace - and a shallow copy breaks that promise the moment there's nesting involved.
structuredClone(), built into Node, makes a deep copy instead: it follows every nested mailroom and row and duplicates all of it, so nothing is shared.
const deepCopy = structuredClone(original);
deepCopy.reviews.average = 1;
console.log(original.reviews.average);
5
This time original.reviews.average stays 5 - deepCopy is a completely independent structure, nested parts included. Reach for structuredClone() whenever you need to edit a copy of something with objects or arrays inside it, and reach for plain spread only when you know there's no nesting to worry about.
Turning data into text with JSON
Objects and arrays only exist while your program is running - to save one to a file, send it over the network, or store it somewhere, it has to become text first. JSON (JavaScript Object Notation) is that text format, and JSON.stringify() is the machine that flattens a value into it.
const order = { title: "Nightjar", quantity: 2, addedAt: undefined };
const text = JSON.stringify(order);
console.log(text);
console.log(typeof text);
{"title":"Nightjar","quantity":2}
string
Notice addedAt is gone - JSON can only carry plain data (numbers, strings, booleans, null, plain objects and arrays), so JSON.stringify() silently drops any slot holding undefined, and it would do the same to a function. A function is a vending machine, not something you could mail in an envelope, so it never survives the trip. JSON.parse() does the reverse, turning text back into a real object:
const restored = JSON.parse(text);
console.log(restored);
console.log(restored.addedAt);
{ title: 'Nightjar', quantity: 2 }
undefined
restored is a brand-new object built from the text - it shares no addresses with order at all, so JSON.stringify() followed by JSON.parse() is actually one more way to make a deep copy, as long as everything inside is plain data. JSON.stringify() also takes a third argument for indentation, handy for text a person will actually read:
console.log(JSON.stringify({ title: "Nightjar", price: 9 }, null, 2));
{
"title": "Nightjar",
"price": 9
}
The null in the middle is a feature this lesson doesn't need (it would let you filter which slots get included) - 2 just says "indent with 2 spaces".
Grouping and counting data
Picture sorting a stack of incoming mail into labeled bins, one bin per sender, instead of one locker per piece - that's grouping. You've already got the tools: .reduce() to walk the data once, and an object (or a Map) to hold one bin per group.
const countsByGenre = catalog.reduce((bins, book) => {
bins[book.genre] = (bins[book.genre] ?? 0) + 1;
return bins;
}, {});
console.log(countsByGenre);
{ poetry: 2, memoir: 2 }
bins[book.genre] reads whatever count is already in that bin - ?? supplies 0 the first time a genre is seen, since the bin doesn't exist yet - then + 1 adds this book, and the result is written straight back into the same slot. One pass through the catalog, one running object, no separate counting step. The same pattern works with a Map instead of a plain object whenever the thing you're grouping by isn't a simple string.
Common mistakes
- Expecting
.sort()to leave the original array alone. It sorts in place and returns that same array - copy first ([...catalog].sort(...)) if you need the original order kept. - Reaching for
||instead of??for a fallback, and losing a real0,"", orfalsevalue because||treats every falsy value as "missing".??only replacesnullorundefined. - Skipping
?.on a chain that might be missing a step, and getting a crash like the one above instead of a cleanundefined. - Assuming
{ ...original }makes a totally separate copy. It only copies the outer slots - anything nested is still shared, which is exactly the bugstructuredClone()fixes. - Expecting
JSON.stringify()to complain about anundefinedvalue or a function. It doesn't error - it just quietly leaves that slot out, which can hide a bug until later. - Treating a Map like a plain object and writing
priceByTitle["Nightjar"]instead ofpriceByTitle.get("Nightjar"). That line doesn't read from the Map's stored pairs at all - it reads an ordinary property named"Nightjar"on the Map object itself, which was never set, so it silently gives backundefined.
Try it
Make a new file in your practice folder and run it after every step.
cd ~/js-basics
code arrays-objects.js
node arrays-objects.js
- Array methods. Build your own small array of objects (anything - songs, chores, places you want to visit) with at least four items and a numeric field. Use
.map()to pull out one field into a new array,.filter()to keep only items passing a test,.find()to get the first match,.reduce()to total the numeric field, and.sort()with a comparator to reorder by that field. Print each result. - Objects and safe access. Make two objects of the same shape, one with a nested property and one missing it. Read the nested property with plain dot notation on the one missing it, and read the error message. Then read it again with
?., and once more with?. ??supplying a fallback. - Destructuring and spread. Destructure two fields out of one of your objects into named variables, and destructure the first two items out of your array using
[a, b] = yourArray. Then spread your array into a new one with one extra item added, and spread one of your objects into a new object with one field overwritten. - Map and Set. Build a
Mapfrom one of your fields to another, using.set()in a loop, then read a value back with.get()and check.size. Build aSetfrom a field that repeats across your items, and confirm its.sizeis smaller than your array's length. - Copying. Make a shallow copy of a nested object with
{ ...yourObject }, change a nested field on the copy, and watch the original change too. Then make a copy withstructuredClone(), change the same nested field, and confirm the original is untouched this time. - JSON. Turn one of your objects into text with
JSON.stringify()and print it. Add a field set toundefinedfirst, stringify again, and notice it's missing from the text. Parse the text back withJSON.parse()and confirm you get a usable object back. - Grouping. Pick a field in your array that repeats (a category, a type, anything with just a few distinct values) and use
.reduce()with an object to count how many items fall into each one, the waycountsByGenredid above.