Chapter 18 — Arrays and Objects
Section 1 — What an array is
An array is a list that holds several values in one variable, in order. Each part of that definition matters. First, "several values in one variable" means you stop inventing a new name for every item: a shopping list with twelve items does not need twelve variables, and a gallery with forty pictures does not need forty. One name holds the whole collection, and it can be passed to a function, stored, or returned as a single thing. Second, "in order" matters: the first item is first for a reason — it might be the top task on a to-do list, the first chapter in a book, or the earliest message in a chat. Order is information, and an array preserves it. Third, an array is a value like any other in JavaScript: you can store it in a variable, put it inside another structure, or inspect it in the console. It is an ordinary container with helpful tools attached.
Think about the alternative. Suppose a page shows three colours a user can pick from. Without an array you might write const colour1 = "red"; const colour2 = "green"; const colour3 = "blue";. That works for three colours, but what happens when the user adds a fourth, or when the list comes from a server and you do not know in advance how many colours there will be? You cannot write a variable name for a number you do not know yet. An array solves this because it grows and shrinks: you can add a colour, remove one, count them, or loop over them, with the same code whether there are three items or three hundred. That is the real power of an array: code that works for any number of items, not just the number you tested with.
In practice, arrays hold the lists a page displays: blog posts, search results, products in a shop, comments under an article — all are arrays in code, even if the visitor only sees cards or rows. When you learn arrays, you are learning to work with the data behind almost every list on the web. A good habit is to ask, whenever you see a repeated thing on a page: is this a list? If yes, the data behind it is almost certainly an array, and your job is to turn it into visible elements, which later chapters will teach. For now, practise reading arrays aloud: ["red", "green", "blue"] is "a list of three strings: red, green, blue." Saying it plainly helps you remember that brackets mean "a list", commas separate items, and each item keeps its position.
const colours = ["red", "green", "blue"];
const scores = [10, 20, 30, 25];
const names = ["Ada", "Bola", "Chidi"];
console.log(colours.length); // 3
console.log(names[0]); // "Ada"
Try this in the console: make an array of your five favourite foods, log its length, and log the first and last items. You needed no five variables, and adding a sixth food is one small change, not a rewrite. That feeling — one container, any number of items, same code — is the whole point of an array.
Check: Why is one array better than three separate variables for a list of colours?
Section 2 — Creating an array
The usual way to create an array is with square brackets, called an array literal. "Literal" simply means you write the value out directly in code, rather than building it step by step. When you write const scores = [10, 20, 30];, you are literally spelling out the array: open bracket, first item, comma, second item, comma, third item, close bracket. JavaScript reads that and creates a list in memory holding those three numbers in that order. This is the most common way arrays appear in real code, because it is short, clear, and easy to read at a glance.
An array literal can hold any kind of value, and the values need not all be the same kind. A list of numbers and a list of strings are both common, and you can mix kinds — ["Ada", 25, true] holds a string, a number, and a boolean — without JavaScript complaining. In real projects, though, mixed arrays are rare, because code handling a list usually expects every item to behave the same way. If you try to double every number in a list that also contains a string, the string will cause trouble. Rule of thumb: you may mix, but usually should not.
There are other ways to create arrays, which you should recognise even though you will use literals most often. new Array(3) creates three empty slots, which is rarely what anyone wants, and Array.from() turns another list-like thing into a real array. The important distinction is between an array with items and an empty array, written as []. It is not an error: it is a real, usable list holding zero items right now. Programs often start empty and add items as they are found, for example collecting valid email addresses from a form or matching search results. Starting empty and growing is a normal, healthy pattern, so do not be surprised by const results = []; at the top of a function.
Watch out for two small writing mistakes. A trailing comma after the last item, like [1, 2, 3,], is allowed by JavaScript and does not create an extra item, but it can look like a mistake, so many teams avoid it. Forgetting a comma between items gives a syntax error that can be confusing the first time you see it. When an array literal throws an error, count the commas first: there should be exactly one fewer comma than there are items. Reading your literal aloud — "open bracket, ten, comma, twenty, comma, thirty, close bracket" — catches both mistakes quickly.
const scores = [10, 20, 30];
const mixed = ["Ada", 25, true];
const empty = [];
console.log(scores); // [10, 20, 30]
console.log(empty.length); // 0
Make a habit of creating small arrays in the console and inspecting them: a list of numbers, a list of strings, an empty list, and a mixed list. The console shows exactly what you made, including order and types. That feedback loop — write a literal, see the list — makes array syntax feel natural, so building a list from real data later feels like ordinary punctuation.
Check: What brackets do you use to write an array literal?
Section 3 — Positions start at zero
The first item in an array is at position 0, not 1. This is zero-based indexing, and it causes more beginner mistakes than any other fact in this chapter, so understand it rather than just memorising it. "Index" means the position number you use to point at an item. In everyday life we count from one, but in JavaScript the first item is item zero. That sounds strange until you think of the index as "how many steps from the start is this?" The first item is zero steps from the start, because it is the start; the second is one step along, the third two steps along. Thinking of an index as a distance makes zero feel natural, not like a trick.
This convention comes from how computers store lists: the first item sits at the starting address and each later item sits steps beyond it. You do not need the memory details, but knowing zero-based indexing is a deliberate, widespread choice — not a JavaScript quirk — helps you accept it, and most languages use it. The practical rule is a simple translation: "first" is index 0, "second" is 1, "third" is 2, "tenth" is 9. Whenever a problem says "the third item", pause and subtract one. That pause prevents bugs where a program shows the next item along and everything looks almost right, which is harder to spot than a complete failure.
Zero-based indexing also explains length and the last index. A three-item array has length 3 but last index 2, because indexes run 0, 1, 2; a ten-item array runs 0 through 9. The rule is: last index equals length minus one. You will see it in loops, which must stop before reaching the length, and in arr[arr.length - 1] for the last item. That expression is just the rule in code: take the length, subtract one, and you have the last position. If you forget whether to subtract one, picture three boxes labelled 0, 1, 2: three boxes, last label 2.
Practise by labelling arrays by hand. Write ["apple", "banana", "cherry"], write 0 under apple, 1 under banana, 2 under cherry, and say each pairing aloud. Do this for a few lists, including a one-item list, where the single item is at index 0 and the length is 1. That case makes the gap visible: first does not mean one, it means zero. Once that clicks, off-by-one errors — being one position out — become something you notice and correct, rather than something mysterious.
const fruits = ["apple", "banana", "cherry"];
console.log(fruits[0]); // "apple" — first item
console.log(fruits[1]); // "banana" — second item
console.log(fruits[2]); // "cherry" — third item
console.log(fruits[fruits.length - 1]); // "cherry" — last item
Try it yourself: make an array of four cities, log the first, second, and last city using length - 1, and check they are what you expect. If you get the second city when you wanted the first, you have an off-by-one error, and now you know what to fix. Zero-based indexing stops being a trap once you expect it.
Check: What is the index of the first item in an array?
Section 4 — Reading an item by its index
You read an item with the array name followed by its index in square brackets, like fruits[1]. This is called indexing, or subscripting, and it is the basic move for getting data out of a list. The brackets after the name do a different job from the brackets that created the array: in creation they meant "make a list", and here they mean "give me the item at this position". JavaScript knows which job you mean from context — brackets right after a variable name mean "look inside this list". The value you get back is ordinary: you can log it, store it, or use it in a calculation. Reading does not change the array.
The index does not have to be a number you type directly. It can be a variable, or an expression. A loop uses this constantly: a variable i counts 0, 1, 2, and fruits[i] gives a different item each time round. You can also compute an index, as in fruits[fruits.length - 1] for the last item, or pick a random item with an index from Math.random(). This turns a static list into something a program can work through: the same line, fruits[i], means something different on each pass because i has changed. When you see a variable inside brackets, ask what values it takes, and you will understand which items the code touches.
What happens when you read an index that does not exist? JavaScript gives you undefined, quietly, without an error. fruits[5] on a two-item array is undefined, and so is fruits[-1], because negative indexes are not counted from the end in JavaScript as they are in some other languages. This quiet behaviour is both convenient and dangerous: a missing item does not crash your page, but a typo or off-by-one error produces undefined that flows into later code and causes a confusing error elsewhere, such as "cannot read a property of undefined". When you see undefined where you expected a value, first check the index: is it within range, from 0 to length minus one? Usually, that is the problem.
Build this habit: store a read item in a clearly named variable before using it, especially if you will use it more than once. const firstFruit = fruits[0]; is easier to read and debug than fruits[0] repeated three times, because the name tells a reader what the value means, and you can log that one variable to check it. If it holds undefined, you know the read failed, and you know exactly where. Read once, name it, then use the name — the same habit professional developers use when pulling an item from a list to display it, where the name explains whether it is a product, a user, or a post.
const fruits = ["apple", "banana"];
console.log(fruits[1]); // "banana"
console.log(fruits[5]); // undefined — no such position
const i = 0;
console.log(fruits[i]); // "apple"
console.log(fruits[fruits.length - 1]); // "banana" — last item
Practise by making an array, reading each item by its index, and deliberately reading one index too far, then trying a variable index that you change. That contrast — a correct read giving a value, an out-of-range read giving undefined — will save you from chasing the wrong bug later.
Check: What do you get when you read an index that does not exist?
Section 5 — The length property
Every array has a length property telling you how many items it holds. A property is information attached to a value, and you read it with dot notation: fruits.length. Note that length has no parentheses, because it is not a method you call — it is a value you read, like a label. Three items means length is 3; none means 0. Add an item and length grows by one; remove one and it shrinks by one. You never count items yourself or update length by hand: JavaScript keeps it in step automatically. That bookkeeping makes length trustworthy for loops and checks.
The relationship between length and indexes is the key to using it well. Indexes run from 0 to length - 1, so length itself is always one past the last index. That is why loops use i < fruits.length, not i <= fruits.length: the loop must stop at the last real index, not step beyond it into undefined. It is also why the last item is fruits[fruits.length - 1], not fruits[fruits.length]. Beginners sometimes think of length as "the last position", and that mistake produces off-by-one errors. Picture instead a row of boxes with a counter beside it: the counter says how many boxes there are, and their labels run one less than the counter.
length is also the standard way to test whether a list is empty, or to decide whether to show "No results found". An empty array has length 0, and results.length === 0 is clear and reliable. Do not test emptiness with if (results), because an empty array is truthy in JavaScript — a surprising fact covered in Section 47 — so that test would claim there are results when there are none. if (results.length) works, since 0 is falsy, but writing the comparison in full, results.length === 0 or results.length > 0, says exactly what you mean. Clarity beats cleverness here.
You can also set length deliberately, though you will rarely need to. A smaller length truncates the array, chopping off items beyond it; a larger length extends it with empty slots. Truncation is occasionally used to empty an array — arr.length = 0 empties it in place — but for ordinary work, prefer clearer methods, like a new empty array or the removal methods in this chapter. The point is that length is not just a read-only label: it is tied to the array's actual size, and changing it changes the array, which is why it always agrees with what you see when you log the array.
const fruits = ["apple", "banana", "cherry"];
console.log(fruits.length); // 3
console.log(fruits[fruits.length - 1]); // "cherry"
const empty = [];
console.log(empty.length); // 0
console.log(empty.length === 0); // true — genuinely empty
fruits.push("pear");
console.log(fruits.length); // 4 — kept in step automatically
Try this: create an array, log its length, push an item and log the length again, then pop an item and log it once more. Watch the number move in step with the list. Then write the emptiness test for a search-results array and the message you would show when its length is zero — a pattern you will use on almost every page that shows a list.
Check: If an array's length is 4, what is its last index?
Section 6 — Changing an item
You change an item by assigning a new value to its index, just as you would assign a value to a variable. The syntax is the same as reading, with an equals sign added: fruits[1] = "pear" means "put pear at position 1". The rest of the array stays exactly as it was — only that one position changes. This is useful whenever one item in a list needs correcting or updating, for example fixing a spelling mistake in a task, updating a score after a game, or replacing a product that is out of stock with its replacement. You do not need to rebuild the whole list, and you do not need a special method. Assignment to an index is the direct, ordinary way to edit a list in place.
Changing an item works even when the array was declared with const. That surprises beginners, because const sounds like "cannot change". What const actually prevents is reassigning the variable itself — you cannot point fruits at a different array — but the contents of the array are still yours to edit. Section 40 returns to this in detail, but it matters here because most arrays in real code are declared with const and then edited freely with index assignment, push, and the other methods in this chapter. If const froze the contents, those methods would be useless, so the language deliberately allows this kind of change.
There is one trap to watch for. Assigning to an index beyond the end of the array does not give an error; it stretches the array and leaves empty gaps in between. If you write fruits[5] = "mango" on a two-item array, you get a six-item array with empty slots at positions 2, 3, and 4. Those gaps behave strangely in loops and are almost never what you intended. If you want to add an item, use push() for the end or splice() for the middle, which are covered later in your wider learning, rather than assigning far beyond the end. Assignment is for replacing an item that already exists, at an index from 0 to length minus one. Keeping that rule — assign only within range — avoids sparse arrays and the confusing bugs they cause.
A good habit is to check your change by logging the array afterwards, especially while learning. Seeing ["apple", "pear"] after changing position 1 confirms both that you picked the right index and that nothing else moved. In a real app, the same idea appears as re-rendering a list after an edit, so the user can see the corrected item. Editing by index is simple, but its simplicity depends on getting the index right, so pause for a second before you write it: which position, counting from zero, is the item you mean?
const fruits = ["apple", "banana"];
fruits[1] = "pear";
console.log(fruits); // ["apple", "pear"]
fruits[0] = "plum";
console.log(fruits); // ["plum", "pear"]
Check: How do you change the second item of an array?
Section 7 — Adding to the end with push
push() adds one or more items to the end of an array. You call it on the array, with the new items in parentheses: fruits.push("banana"). After the call, the array is longer, the new items are at the end in the order you gave them, and everything that was already there keeps its position. This is the most common way to grow a list, because lists usually grow at the end: new tasks go at the bottom of a to-do list, new messages go at the bottom of a chat, and new search results are appended to the ones already shown. push() matches that everyday pattern, which is why you will use it far more than the start-of-array methods.
push() also returns a value: the new length of the array. Most of the time you ignore that return value, because you only care that the item was added. But it can be handy in a condition or a log message, for example confirming that a list now holds ten items. Do not confuse the return value with the array itself: push() does not return the new array, it returns a number, and it changes the original array in place. Code like const newList = fruits.push("banana") is a classic beginner bug, because newList ends up holding a number, not a list. If you need a new array, use spread or map or filter instead; if you want to grow the existing array, call push() on its own line and then use the array as before.
You can push several items in one call, separated by commas, and they are added in order. You can also push a value that comes from a variable, from user input, or from a function result — the method does not care where the value came from, only that it is a value. That makes push() the natural partner of loops that collect results: start with an empty array, loop over some data, push each wanted result, and finish with a full list. You will see this collect-and-push pattern in filtering, in building display lists, and in gathering form values, so practise it until it feels automatic.
One more detail: push() works on an empty array just as well as on a full one. Pushing onto [] gives a one-item array, which means the "start empty, push as you go" pattern needs no special case for the first item. That uniformity is part of why the pattern is so popular. Try it in the console: start empty, push three foods one at a time, and log the array after each push. Watching it grow step by step makes the in-place change concrete, and it shows why push() is described as mutating the array — a word you will meet again in Section 48, meaning it changes the original rather than returning a new copy.
const fruits = ["apple"];
fruits.push("banana");
console.log(fruits); // ["apple", "banana"]
fruits.push("cherry", "pear");
console.log(fruits.length); // 4
Check: What does push() do, and where does it add the new item?
Section 8 — Removing from the end with pop
pop() removes the last item from an array and returns that item. It takes no arguments — you simply call fruits.pop() — and after the call the array is one item shorter. It is the mirror image of push(): push() grows the array at the end, pop() shrinks it at the end, and together they make an array behave like a stack, a pile where you add to the top and take from the top. Stacks appear in real programs more often than you might expect: an undo history, where the most recent action is the first to undo, or a browser's back button history, are stack-like, and push() and pop() are the tools that build them.
The return value of pop() is the part beginners most often miss. Unlike push(), whose return value is usually ignored, pop()'s return value is usually the whole point: it is the item you removed, and you normally store it or use it straight away. In the example, const last = fruits.pop() puts "banana" into last and leaves fruits holding only "apple". If you call pop() without storing the result, the item is gone for good, which is fine when you only wanted to shorten the list, but wasteful when you wanted to use the removed item, for example to show "Removed: banana" to the user. So the habit is: if you need the removed item, capture it; if you only need the shorter list, let it go.
Calling pop() on an empty array does not crash; it returns undefined and leaves the array empty. That quiet behaviour is consistent with reading a missing index, and it carries the same warning: a undefined result may mean you popped once too often, or that the array was already empty when you expected it to hold something. When a pop() gives undefined unexpectedly, check the array's length before the call, and check whether an earlier part of your code already removed the item. As with other array methods, logging the array before and after a pop() while you are learning makes the change visible and removes the guesswork.
pop() is also a good example of a mutating method: it changes the original array rather than returning a new, shorter copy. If another variable points at the same array, it will see the shorter version too, because there is only one array and both variables refer to it. That sharing behaviour is covered in Sections 34 and 39, but it is worth noticing here, because it explains why pop() in one part of a program can affect a list displayed in another part. For now, remember the pairing: push() adds at the end and returns the new length, pop() removes from the end and returns the removed item.
const fruits = ["apple", "banana"];
const last = fruits.pop();
console.log(last); // "banana"
console.log(fruits); // ["apple"]
console.log([].pop()); // undefined
Check: What does pop() return?
Section 9 — Adding and removing at the start
unshift() adds items to the start of an array, and shift() removes the first item and returns it. They are the start-of-array twins of push() and pop(), and they follow the same return-value pattern: unshift() returns the new length, which you usually ignore, while shift() returns the removed item, which you usually keep. The names are worth learning carefully, because they are easy to mix up. A memory aid is that "shift" suggests moving things along: shift() shifts everything down by removing the first item, while unshift() does the reverse by inserting at the front. Another aid is the pairing with the end methods: push and pop work at the end, shift and unshift work at the start, and in each pair the method that adds is the one whose name is longer or less familiar.
When would you actually add to the start? Less often than to the end, but the cases are real. A feed that shows the newest post first might add each new post to the front, so the newest is always at index 0. A list of recent searches might do the same. Removing from the start fits a queue, where the first item to arrive is the first to be handled — like a queue of tasks waiting to be processed, or messages waiting to be sent. The pair push() plus shift() builds a queue: add new work at the end, take work from the front, in order. The pair push() plus pop() builds a stack, as Section 8 explained. Recognising queue versus stack helps you pick the right methods without having to reason from scratch each time.
There is a performance note worth knowing, even though it rarely matters in beginner projects. Adding or removing at the start forces every other item to move one position, because all the indexes shift. Adding at the end touches nothing else. For a list of ten items the difference is invisible, but for a list of tens of thousands it can be noticeable, which is why large programs sometimes avoid unshift() and shift() in tight loops. Do not let this stop you using them where they are the clearest choice; clarity matters more than speed until a measurement shows a problem. Just know that "at the start" is a slightly more expensive operation than "at the end", and prefer push() when the order does not require the front.
Practise the four methods together, because their symmetry is the lesson. Start with ["banana"], unshift("apple") to get ["apple", "banana"], push("cherry") to get three items, shift() to remove "apple", and pop() to remove "cherry". After each step, log the array and the return value. That short drill teaches the positions, the return values, and the in-place change all at once, and it gives you a mental model — a list with two ends, each with an add and a remove — that you can reuse whenever you need to manage a list's ends.
const fruits = ["banana"];
fruits.unshift("apple");
console.log(fruits); // ["apple", "banana"]
console.log(fruits.shift()); // "apple"
console.log(fruits); // ["banana"]
Check: Which method adds to the start, and which removes from the start?
Section 10 — Looping with a for loop
A classic for loop walks through an array by counting from 0 up to, but not including, length. The usual shape is for (let i = 0; i < fruits.length; i++), and each part has a job. let i = 0 creates a counter starting at the first index. i < fruits.length is checked before every pass: while true, the loop runs again; when false, it stops. i++ adds one after every pass, moving to the next index. Inside, fruits[i] gives the current item, so the same code handles a different item each time round. That combination — counter, stopping condition, index — lets a loop process any number of items with a fixed amount of code.
The condition is the part to get right. It must be < length, not <= length, because the last real index is length - 1. With <=, the loop would run one extra time, when i equals length, and fruits[length] is undefined. That extra pass might only log undefined, or it might crash later code that tries to use the missing item. The condition is also checked against the live length each time, which means that if you change the array's length inside the loop — by pushing or popping — the loop's stopping point changes too. That is usually a mistake, not a feature: changing a list while looping over it is a common source of skipped items and infinite loops, so as a rule, do not add or remove items inside a loop over that same array unless you have thought carefully about what will happen to the counter.
The counter i, short for "index", is fine for simple loops. In longer loops a clearer name like index can help, but i is so standard that readers expect it and recognise an ordinary counting loop. What matters more is scope: let i in the for header exists only inside the loop, so it cannot leak into the rest of your code or clash with another loop's counter. Using var instead would leak it, which is one reason modern code prefers let here. The loop also gives you both index and item, an advantage over for...of when you need to know where you are — for example, to print "1. apple, 2. banana", or to change the item at the current index.
A loop is not magic; it is repetition with a changing value. If a loop confuses you, unroll it by hand: write out what happens when i is 0, then when i is 1, then when i is 2, and check when the condition fails. That manual walkthrough turns an abstract loop into three concrete steps, and it is the fastest way to find an off-by-one error or a wrong condition. Practise by looping over an array of your own, logging both i and fruits[i] each time, so you can see the counter and the item moving together. Once that picture is clear, loops over arrays become a tool you trust, not a pattern you copy blindly.
const fruits = ["apple", "banana", "cherry"];
for (let i = 0; i < fruits.length; i++) {
console.log(i, fruits[i]);
}
// 0 "apple", 1 "banana", 2 "cherry"
Check: Why does the loop condition use < length instead of <= length?
Section 11 — Looping with for...of
for...of is a simpler loop for when you only need each value, not its index. Its shape is for (const fruit of fruits), which reads almost like English: "for each fruit in fruits, do this". On each pass, the variable takes the next value in the array, in order, and the loop stops by itself after the last item. There is no counter to start, no condition to get wrong, and no i++ to forget, which removes the three most common loop mistakes at once. That simplicity is the point: when the job is "do something with every item", for...of says so directly, and a reader can see the intent without decoding a counting mechanism.
The trade-off is that for...of does not give you the index. If you need to know which position you are at — to number a list, to change the item in place, or to compare an item with its neighbour — a counting loop or forEach() with its index argument is a better choice. You can work around the missing index by keeping your own counter variable outside the loop and increasing it each pass, but that reintroduces the bookkeeping for...of was meant to remove, so it is usually a sign you should have used a different loop. Choose the loop that gives you what you need: values only means for...of; values plus positions means a counting loop or forEach().
for...of works on more than arrays. It works on strings, character by character, and on other iterable collections you will meet later, such as a NodeList from the DOM. That generality is a strength, but it also means for...of does not work on plain objects, because objects are not ordered lists and have no built-in iteration order of values in that sense. Trying for...of on an object gives an error, and the fix is to loop over its keys with for...in or over Object.keys() instead, as Sections 30 and 31 explain. Remembering "for...of is for lists, for...in is for objects" prevents that error, and it is a useful pairing to keep in mind.
Another detail is the declaration inside the loop header. const fruit is the usual choice, because each pass gets a fresh variable holding that pass's value, and you do not reassign it. Using let also works, but const signals that the variable is not meant to change during a single pass, which makes the code slightly easier to trust. The variable's name should say what the items are — fruit, user, product — not just item or x, because a good name turns the loop into a sentence a reader can follow. Practise by rewriting a counting loop you already wrote as a for...of loop, and notice how much shorter and clearer it becomes when the index was never needed in the first place.
const fruits = ["apple", "banana", "cherry"];
for (const fruit of fruits) {
console.log(fruit);
}
let total = 0;
for (const score of [10, 20, 30]) {
total += score;
}
console.log(total); // 60
Check: When would you choose for...of over a counting for loop?
Section 12 — forEach
forEach() runs a small function once for every item in an array, passing the item each time. You write fruits.forEach(function (fruit) { ... }), and JavaScript calls your function with the first item, then the second, then the third, until the array is done. This is a different style from a loop: instead of writing the repetition yourself, you hand JavaScript a function and let it do the repeating. The function is called a callback, because JavaScript calls it back for each item. Callbacks take some getting used to, but they are central to JavaScript, so forEach() is a gentle first example: a small function, called repeatedly, with a different argument each time.
The callback can receive up to three arguments: the item, its index, and the whole array. Most of the time you use only the first, and sometimes the second, for example to print a numbered list. You do not have to name them fruit and index; any sensible names work, but the order is fixed — item first, index second — so naming them in the wrong order is a real bug. Inside the callback you do whatever the job needs: log the item, add it to a page, add it to a total, or check it against a condition. The key point is that forEach() itself returns undefined. It does not build a new array, it does not return a total, and it does not return a found item. If you need a result, collect it in a variable outside the callback, as in the total example, or use a method that returns what you need, like map() or filter().
There are two limitations worth knowing. First, you cannot break out of a forEach() early with break, and you cannot skip an item with continue, because you are inside a function, not a loop body. A return inside the callback only ends that one call, not the whole iteration, so the next item still gets processed. If you need to stop early — for example, when you have found the item you were looking for — use a for loop with break, or find(). Second, forEach() skips empty slots in a sparse array, which is usually fine but can surprise you if you created gaps by assigning beyond the end, as Section 6 warned against. For ordinary, dense arrays, neither limitation matters, and forEach() is a clean, readable way to act on every item.
Choose forEach() when the job is a side effect: doing something with each item, rather than building a new value. Logging, displaying, and updating are side effects; transforming and filtering are not, and they have their own methods. That distinction — side effect versus new value — is one of the most useful habits in this chapter, because it tells you which method to reach for before you write any code. If a reader sees forEach(), they should expect effects, not a result, and if they see map(), they should expect a new array. Writing code that matches those expectations makes your programs easier for others, and for future you, to read.
const fruits = ["apple", "banana"];
fruits.forEach(function (fruit) {
console.log(fruit);
});
fruits.forEach(function (fruit, index) {
console.log(index + 1 + ". " + fruit);
});
Check: Does forEach() return a new array? What is it for instead?
Section 13 — map: transforming every item
map() builds a new array by running a function on every item and collecting the results, in order. If you map over [1, 2, 3] with a doubling function, you get [2, 4, 6]: same length, same order, each item transformed. The original array is left unchanged, which is an important promise. Code that calls map() does not have to worry that the input list has been altered somewhere else, and a reader who sees map() knows the original is safe. That safety is why map() is preferred over a loop that pushes into a new array for simple transformations, even though both can achieve the same result: map() states the intent — "a new list, same shape, transformed items" — in a single word.
The callback for map() must return a value, because that value becomes the item in the new array. Forgetting the return is the most common map() bug, and it produces an array of undefined values, one per input item. The length will look right, which makes the bug confusing at first, until you inspect the contents and see nothing but undefined. When that happens, check the callback's return statement first. In modern JavaScript, arrow functions with a short body, like n => n * 2, return their expression automatically, which removes this trap for one-line transformations. Whether you use a traditional function or an arrow function, the rule is the same: no return, no transformed value.
map() shines when the transformation is the same for every item. Doubling numbers, uppercasing strings, extracting one property from each object, or turning data into display strings are all map() jobs. It is less suited to jobs where some items should be dropped — that is filter() — or where items should be combined into a single value — that is reduce(), which you will meet later. Trying to force map() to do those jobs leads to awkward code, such as returning undefined for unwanted items and then cleaning them up. A clean rule is: one input item in, one output item out, for every item, with no exceptions. If your job breaks that rule, pick a different method.
Real front-end code uses map() constantly, often in chains. A list of user objects becomes a list of names with one map(), and a list of products becomes a list of prices with another. Later, in the DOM chapter, a list of data items becomes a list of page elements with the same idea, except the transformation builds elements instead of strings. Practise now with numbers and strings, where the transformation is easy to check, and the pattern will transfer directly: the input list, the function, and the new list are the same three parts, whatever the items are.
const numbers = [1, 2, 3];
const doubled = numbers.map(function (n) { return n * 2; });
console.log(doubled); // [2, 4, 6]
console.log(numbers); // [1, 2, 3] — unchanged
const names = ["ada", "bola"];
console.log(names.map(function (n) { return n.toUpperCase(); }));
Check: If you map over [1, 2, 3] and double each number, what new array do you get?
Section 14 — filter: keeping only some items
filter() builds a new array containing only the items for which your function returns true. Items that fail the test are left out, and the original array is unchanged. The test function is called a predicate, a function that answers a yes-or-no question about an item: "is this number even?", "is this product in stock?", "does this post belong to this author?" Each item gets asked the question, and only the yes answers survive into the new array. The result can be shorter than the original, the same length if everything passes, or empty if nothing passes — and an empty result is a valid result, not an error, meaning simply that nothing matched.
Like map(), filter() depends on its callback returning something, but here the return value is treated as true or false, not kept as an item. Returning a truthy value keeps the item; returning a falsy value drops it. The item itself goes into the new array unchanged, not the return value, which is a crucial difference from map(). Confusing the two is a common beginner mistake: using filter() with a callback that returns a transformed value will keep or drop items based on whether that value is truthy, which is rarely the intended logic. Keep the roles straight: map() returns the new item, filter() returns yes or no about the old item.
Filtering is how real apps build the lists users actually see. A shop page rarely shows every product at once; it shows the products that match the selected category, are in stock, and are under a price limit. Each of those conditions is a filter, and they can be combined in one test function with &&, or applied in a chain of filter() calls, one condition at a time. Chaining is often clearer, because each step has a name-like purpose: first filter by category, then by stock, then by price. The intermediate arrays are small and easy to inspect, and the final result is the same as a single complicated test. When a filtered list comes out wrong, check the test function with a single known item first: does it return true for an item that should be kept, and false for one that should be dropped? That one-item test finds most filter bugs.
Note also that filter() does not change the items it keeps. If the items are objects, the new array holds the same objects, not copies, so changing a property of an object in the filtered array changes it in the original too. That sharing is the same reference behaviour covered in Sections 34 and 39, and it is usually what you want — a filtered view of the same data, not a duplicate. Just be aware of it when you edit filtered results, and use spread to copy an object first if you need an independent version.
const numbers = [1, 2, 3, 4];
const evens = numbers.filter(function (n) { return n % 2 === 0; });
console.log(evens); // [2, 4]
console.log(numbers); // [1, 2, 3, 4] — unchanged
const words = ["cat", "elephant", "dog"];
console.log(words.filter(function (w) { return w.length > 3; }));
Check: What does filter() return for items that fail the test?
Section 15 — find: the first match
find() returns the first item that passes a test, or undefined if none passes. Use it when you expect one particular item, not a list of matches: the user with a given id, the product with a given code, the first task that is overdue. The test function works exactly like filter()'s test — a yes-or-no question about each item — but find() stops as soon as it gets its first yes, instead of checking every item and collecting all the yeses. That early stopping is both an efficiency win and a statement of intent: the code is saying "there is one item I want, and the first match is the one".
The difference from filter() matters in what you get back and how you use it. filter() always returns an array, even when only one item matches, so you would have to take [0] from it to get the item, and handle the empty-array case separately. find() returns the item itself, or undefined, so the natural follow-up is a check like if (found) { ... } or optional chaining. That check is essential: forgetting it is the most common find() bug, because code that assumes a match was found will crash on undefined when the search fails. Always handle the not-found case, even if you think the item must exist. Data from a server, from user input, or from an earlier filter can surprise you, and a graceful "not found" message is far better than a crash.
find() also has a companion, findIndex(), which returns the position of the first match, or -1 if none matches, instead of the item itself. Use findIndex() when you need to change or remove the found item in the original array, because the index tells you where it lives. The two methods share the same test logic, so learning one teaches the other: the question is only whether you want the item or its position. A useful habit is to ask, before you write the call, "do I want the thing, or where the thing is?" That question picks the method for you.
Practise find() with a small array of objects, such as users with ids, and search for an id that exists and one that does not. Log both results and notice the difference between an object and undefined. Then try the same search with filter() and compare the shapes of the results: an array with one object versus the object itself. That comparison is the lesson in miniature, and it will help you choose correctly in real code, where picking the wrong one leads to either an extra [0] everywhere or a crash on a missing item.
const numbers = [1, 2, 3];
console.log(numbers.find(function (n) { return n > 1; })); // 2
console.log(numbers.find(function (n) { return n > 10; })); // undefined
const users = [{ id: 1, name: "Ada" }, { id: 2, name: "Bola" }];
console.log(users.find(function (u) { return u.id === 2; }));
Check: What is the difference between find() and filter()?
Section 16 — includes and indexOf
includes() tells you whether an array contains a value, returning true or false, and indexOf() returns the index of a value, or -1 if it is not there. The two methods answer related but different questions: "is it here?" versus "where is it?" Choosing between them is a matter of which answer you need. If your code only needs to decide whether to show a warning, allow an action, or skip a duplicate, includes() is clearer, because its true-or-false result reads directly as a condition: if (fruits.includes("banana")). If you need to change or remove the found item, you need its position, so indexOf() is the right tool, and its -1 result doubles as the "not found" signal you must check before using the index.
Both methods search using strict equality, the same === you use in conditions. That means "2" does not match 2, and true does not match 1, even though loose equality would blur those distinctions. Strictness is a good thing here, because it prevents surprising matches, but it also means you must be careful about types: a number read from an input field arrives as a string, and searching a number array for that string will fail. Converting the input to a number first, or storing strings consistently, avoids that trap. The same strictness applies to objects: includes() and indexOf() check whether the array holds that very object, not merely an object with the same contents, for the identity reasons explained in Section 39. Searching an array of objects for a matching property value therefore needs find() or findIndex(), not includes().
indexOf() finds the first occurrence when a value appears more than once, and there is a companion, lastIndexOf(), that finds the last. For most purposes the first occurrence is enough, especially when you are checking for duplicates before adding an item: if indexOf() returns -1, the item is not there yet, so it is safe to push. That duplicate check is a common real pattern, for example preventing the same tag from being added twice to a list. Writing it with includes() is even clearer — if (!tags.includes(newTag)) tags.push(newTag) — and it reads as a sentence. Either form works; pick the one whose result your next line actually uses.
A final note on -1: it is a deliberate choice, not a bug. Because 0 is a valid index, "not found" cannot be 0, so the designers chose -1, a value that can never be a real index. That is why the check is always index !== -1 or index > -1, never a truthiness test, because index 0 is falsy in a loose sense only if you misuse it — actually 0 is falsy, so if (index) would wrongly treat a found-at-zero item as not found. This is a classic bug, and it is worth naming explicitly: never test an index with if (index). Test it against -1. That one rule saves you from a bug that only appears when the wanted item happens to be first in the list, which is exactly the case your tests might miss.
const fruits = ["apple", "banana", "apple"];
console.log(fruits.includes("banana")); // true
console.log(fruits.includes("pear")); // false
console.log(fruits.indexOf("apple")); // 0
console.log(fruits.indexOf("pear")); // -1
if (fruits.indexOf("pear") === -1) {
fruits.push("pear");
}
Check: What does indexOf() return when the value is not in the array?
Section 17 — Sorting numbers needs care
By default, sort() turns items into strings and sorts them alphabetically, so sorting [10, 2, 30] without help gives [10, 2, 30] — because as strings, "10" comes before "2", since "1" comes before "2" in character order. This is not a bug in the sense of a mistake in the language; it is the documented default, designed for strings, and it works perfectly for alphabetical lists of words. The problem is only that beginners assume sort() knows they mean numbers. It does not. It sees values, converts them to strings for comparison, and orders them by character, so any list of numbers with different digit lengths will come out in an order that looks wrong for maths, even though it is correct for spelling.
The fix is a compare function: a small function sort() calls repeatedly with pairs of items, asking "which comes first?" It returns a negative number if the first item should come before the second, a positive number if after, and zero if equal. For numbers, function (a, b) { return a - b; } does exactly that: if a is smaller, the subtraction is negative, so a comes first, giving ascending order. Reversing it to b - a gives descending order. You do not need to know which pairs sort() compares, or in what order — the algorithm handles that — only a correct rule for any single pair, from which sort() builds the full order. That separation, rule versus algorithm, recurs in filtering and searching.
Sorting strings has its own care to take. Plain sort() orders strings by their character codes, which puts uppercase letters before lowercase ones, so "Zebra" would come before "apple". For user-facing lists, a better comparison uses localeCompare(), as in a.localeCompare(b), which orders words the way a human reader in a given language would expect, handling case and accents sensibly. You do not need to master locale rules now, but you should know that the default string sort is code-based, not dictionary-based, and that localeCompare() exists for the human-friendly version. Similarly, sorting dates means comparing their underlying time values, not their display strings, and sorting objects means comparing one property, as Section 44 shows.
One more warning: sort() changes the original array in place and returns that same array, not a new one. Code that sorts a list for display and then expects the original order elsewhere will be surprised, because the original order is gone. The safe habit, when you need both orders, is to copy first with spread — [...numbers].sort(...) — and sort the copy. That one extra step preserves the source data and makes the intent clear: this sorted version is a view, not a replacement. Try both forms in the console and log the original afterwards, so the difference between sorting in place and sorting a copy becomes something you have seen, not just something you have read.
const numbers = [10, 2, 30];
numbers.sort(function (a, b) { return a - b; });
console.log(numbers); // [2, 10, 30]
const words = ["banana", "Apple", "cherry"];
console.log([...words].sort(function (a, b) { return a.localeCompare(b); }));
console.log(words); // unchanged, because we sorted a copy
Check: Why does sorting numbers without a compare function give a surprising result?
Section 18 — Joining an array into a string
join() joins all items into one string, with a separator you choose. The separator is placed between each pair of items, not after every item and not before the first one, so ["a", "b", "c"].join("-") gives "a-b-c", with two separators for three items. If you call join() with no argument, the separator defaults to a comma, which is why logging an array often looks comma-separated. The original array is unchanged: join() returns a new string and leaves the list intact, so you can join the same array in different ways — commas for a sentence, line breaks for a list, or slashes for a path — without affecting the data.
Joining is the reverse of splitting, and the pair is used constantly. split() turns a string into an array by cutting at a separator, and join() turns an array back into a string by gluing with a separator. Together they let you move between the two shapes whenever a job suits one better than the other. A common example is processing text: split a sentence into words to count or filter them, then join the survivors back into a sentence. Another is building display text from a list, such as turning ["Ada", "Bola", "Chidi"] into "Ada, Bola and Chidi" — though that last "and" needs a little extra handling, because a plain join would give "Ada, Bola, Chidi", which is a reminder that join() is a simple tool, not a sentence formatter. For ordinary comma-separated or space-separated output, however, it is exactly right.
The separator choice deserves a moment's thought, because it affects how the result can be read back. Joining with a comma works until an item itself contains a comma, at which point splitting the result later would cut in the wrong places. That is why data formats like CSV have quoting rules, and why you should not invent a join-and-split storage format with a separator that can appear in the data. For display only, any separator is fine, because no code will need to split it again. For storage or transmission, prefer a proper format like JSON, covered in Sections 36 and 37, rather than a handmade joined string. Knowing the difference — display joining versus data encoding — keeps a convenience from becoming a fragile format.
Non-string items are converted to strings when joined, so [1, 2, 3].join(" + ") gives "1 + 2 + 3", and joining an array containing undefined or null gives empty strings in those positions. That conversion is usually helpful, but it can hide missing data, because a gap becomes invisible in the joined string. If a joined result looks short or has a doubled separator, check the original array for missing values before blaming the join. Practise by joining the same array with several separators — a space, a comma and a space, a dash, and a line break — and notice how the same data reads differently in each form. That experiment teaches the core idea: the array is the data; join() is one way of presenting it as text.
const words = ["hello", "world"];
console.log(words.join(" ")); // "hello world"
console.log(words.join(", ")); // "hello, world"
console.log(["a", "b", "c"].join("-")); // "a-b-c"
console.log("a,b,c".split(",")); // ["a", "b", "c"]
Check: What does ["a", "b"].join("-") produce?
Section 19 — Copying an array with spread
The spread syntax (...) copies the items of one array into a new array. Writing [...first, 3] creates a fresh array whose first items are copies of first's items, followed by 3, while first itself is untouched. This matters because the alternative, const second = first, does not copy anything: it makes a second name for the same array, so changing second changes first as well, because there is still only one list in memory with two names pointing at it. Beginners are often bitten by this, because assignment looks like copying for numbers and strings, where it genuinely does copy the value. For arrays and objects, assignment copies the reference — the pointer to the shared thing — not the thing itself, and spread is the tool that actually makes a new list.
Spread copying is shallow, which means it copies the items themselves, but if an item is itself an object or an array, the copy shares that inner object with the original. For a list of numbers or strings, that distinction does not matter, because numbers and strings are copied by value anyway. For a list of objects, it matters a great deal, as Section 34 explains for objects: changing a property of an object in the copied array changes the same object in the original array. So spread gives you an independent list — you can push, pop, and sort the copy without affecting the original's length or order — but not independent items. If you need independent items too, you must copy each item as well, for example by mapping over the array and spreading each object.
Spread is also the clean way to combine arrays and add items without mutating the original. [...first, ...second] concatenates two lists into a new one, and [...first, newItem] adds an item to a new list, leaving the old list as it was. This non-mutating style is popular in modern front-end code, because it makes data flow easier to reason about: a function returning a new list has not secretly changed its input. That is the same idea behind map() and filter(), which also return new arrays, contrasting with push(), pop(), and sort(), which change the original. Neither style is always right, but knowing which one code uses is essential, and spread is the most readable way to say "a new list, based on this one, with a change".
Try the distinction in the console, because seeing it is more convincing than reading about it. Assign one array to a second variable, push onto the second, and log the first — it will have grown too. Then make a spread copy, push onto the copy, and log the original — it will be unchanged. That pair of experiments fixes the reference idea in your mind, and explains bugs where a list changes "by itself" because another part changed a shared copy.
const first = [1, 2];
const second = [...first, 3];
console.log(second); // [1, 2, 3]
console.log(first); // [1, 2] — unchanged
const alias = first;
alias.push(99);
console.log(first); // [1, 2, 99] — shared, not copied
Check: Why is [...first] safer than const second = first when you plan to change the copy?
Section 20 — Destructuring an array
Destructuring unpacks array items into separate variables in one line. Writing const [first, second] = ["apple", "banana", "cherry"] creates two variables, first holding "apple" and second holding "banana", and ignores "cherry" because there is no third variable for it. The order matters completely: the first variable gets the first item, the second gets the second, and so on, because arrays are ordered and destructuring follows that order. This is different from object destructuring, where names matter and order does not, and confusing the two is a common early mistake. If you swap the variable names, you swap which values they hold, because position, not name, does the matching.
Destructuring becomes more useful when the array comes from somewhere else, not from a literal you just wrote. A function that returns an array of two values — for example, a pair like [width, height], or a result and an error — can be unpacked at the call site into two clearly named variables, instead of indexing into the result twice. That is clearer than const result = getSize(); const width = result[0]; const height = result[1];, because the single destructuring line shows the shape of the data and the names of its parts together. It also works on any array, including one built by another part of your program, so it is a general tool for "take this list apart into named pieces", not just a shortcut for literals.
You can skip items by leaving a gap in the pattern, as in const [, second] = fruits, where the empty first position means "ignore the first item, I only want the second". You can also give default values, as in const [first = "none", second = "none"] = fruits, which apply when the array is shorter than the pattern and the position would otherwise be undefined. Defaults are handy for optional values, such as a function argument list where later values may be missing. You can even collect the rest into a new array with the rest syntax: const [first, ...rest] = fruits puts the first item in first and all remaining items, as a new array, in rest. That combination — take the head, keep the tail as a list — is a classic pattern in list processing, and it reads clearly once you know the syntax.
Destructuring is a convenience, not a requirement, and overusing it can hurt readability. Unpacking ten items into ten variables in one line is harder to follow than a loop or a named object, and a long destructuring pattern hides which items are actually used. A good rule is to destructure when you need a few items by position and their names make the code clearer, and to leave the array whole when you need to loop over it or pass it on. Used sparingly, destructuring makes code shorter without making it cryptic, and it is a skill worth practising on small examples before you meet it in larger codebases, where it appears in function parameters, in return values, and in framework code.
const [first, second] = ["apple", "banana", "cherry"];
console.log(first); // "apple"
console.log(second); // "banana"
const [head, ...tail] = ["a", "b", "c"];
console.log(head); // "a"
console.log(tail); // ["b", "c"]
const [, onlySecond] = ["a", "b"];
console.log(onlySecond); // "b"
Check: In array destructuring, what decides which variable gets which item?
Section 21 — Arrays inside arrays
An array can hold other arrays, which is handy for grids, rows, or groups. A spreadsheet, a chessboard, or a seating plan all have two levels: the outer list holds rows, and each row holds items. In code that looks like [[1, 2], [3, 4]]: the outer array has two items, each itself an array of two numbers. You reach an inner item with two indexes, one after the other: grid[1][0] means "row 1, then item 0 inside that row", which is 3 here. Reading left to right, each bracket descends one level, though code with three or four levels of brackets usually signals that a clearer shape, such as objects, would help.
Nested arrays are often built from real groupings. A week might be an array of seven days, each day an array of tasks. A shop's shelves might be an array of rows, each row an array of products. A game board might be an array of rows, each row an array of cells. In each case the nesting mirrors the real structure, which makes the code easier to reason about than a single flat list where you have to calculate row boundaries by hand. Looping over a nested array usually means two loops: an outer loop over rows, and an inner loop over the items in each row. That nested-loop pattern is a fundamental programming shape, and it is worth practising on a small grid, logging each row and then each item inside it, until the two-level walk feels natural.
There are pitfalls specific to nesting. The first is assuming every row has the same length. A grid from real data might have ragged rows — one with three items, the next with five — and code assuming a fixed row length will read undefined past the end of a short row. Use each row's own length in the inner loop, rather than a single length for all rows. The second pitfall is shared references: filling a grid with the same inner array repeated means every row points at the same list, so changing one row changes them all. Fix it by creating a fresh inner array for each row, inside the outer loop, rather than reusing one made outside it. That bug is subtle because the grid looks right until the first edit, when every row changes at once.
Nested arrays can also hold other groups, such as coordinate pairs, or lists of comments grouped by post. When inner lists have meanings beyond "row", consider whether objects with named properties would be clearer, as Section 28 discusses. A row of numbers is fine as an array, but a record with a name, price, and stock count is clearer as an object. The choice is not about what is possible — arrays can hold anything — but about what a reader understands at a glance. Use nested arrays for genuine grids and groups, and objects when each position would need a label.
const grid = [[1, 2], [3, 4]];
console.log(grid[1][0]); // 3
console.log(grid[0].length); // 2
for (const row of grid) {
for (const cell of row) {
console.log(cell);
}
}
Check: How do you read the first item of the second row in the grid above?
Section 22 — What an object is
An object groups related values together under named keys, instead of numbered positions. A person has a name, an age, and a city, and those three facts belong together because they describe the same person. An object holds them together as one thing, so you can pass a person around your program, store a person, or display a person, without juggling three separate variables that might get out of step. If you kept personName, personAge, and personCity as separate variables, a function that needed a person would need three arguments, and a list of people would need three parallel lists that must stay aligned. An object removes that fragility by bundling the facts with the thing they describe.
The key idea is naming. In an array, position is the only way to tell items apart, so the reader must remember that position 0 is the name and position 1 is the age. In an object, each value carries its own label — the key — so person.name and person.age explain themselves. That self-explanation is why objects dominate real front-end data: a user, a product, a post, a comment, and a setting are all naturally described by named properties, and code that reads product.price is immediately clearer than code that reads product[1] and hopes position 1 is the price. When a new property is added to an object, existing code keeps working, because it refers to properties by name, not by position. Adding a field to an array-based record, by contrast, shifts every position after it and breaks code that assumed the old layout.
Objects are also the shape of most data that arrives from elsewhere. A server responding to a request usually sends objects, or arrays of objects, describing users, products, or articles. The JSON format, covered in Sections 36 and 37, is built around objects for exactly this reason. Learning objects therefore prepares you directly for working with real APIs, where you will spend much of your time reading properties from objects you did not create, and checking whether a property you expect is actually present. The skills in this part of the chapter — creating, reading, changing, and looping over objects — are the daily tools of that work.
It helps to think of an object as a small dictionary or a form. A dictionary maps words to definitions; an object maps keys to values. A form has labelled fields, and each field holds one answer; an object has labelled properties, and each property holds one value. Neither analogy is perfect — dictionaries are for lookup, and objects can also hold functions, as Section 27 shows — but both capture the central idea: a set of named slots, each holding a value, grouped under one name. When you are deciding how to model something, ask whether its parts have names. If they do, and if different parts mean different things, an object is almost certainly the right container, and Section 28 gives you a decision guide for the borderline cases.
const person = { name: "Ada", age: 25, city: "Lagos" };
console.log(person.name); // "Ada"
console.log(person.city); // "Lagos"
const product = { name: "Notebook", price: 1500, inStock: true };
console.log(product.price); // 1500
Check: When is an object a better choice than an array?
Section 23 — Creating an object
The usual way to create an object is with curly braces, called an object literal. Inside, you write key-value pairs separated by commas: the key is the name, the value is the data. In { title: "Dune", pages: 412 }, title and pages are keys, and "Dune" and 412 are their values. The colon separates a key from its value, and the comma separates one pair from the next — a different job from arrays, where commas separate bare values. Reading an object literal aloud helps: "an object with title Dune and pages 412". That sentence is the data, and the literal is just a compact way of writing it.
Keys are usually plain identifiers — words of letters, digits, underscores, and dollar signs, not starting with a digit — like name, age, or city. Such keys can be written without quotes, and they are the norm in code you write yourself. But keys can also be quoted strings, necessary when a key contains a space, a hyphen, or starts with a digit, as in { "first name": "Ada" } or { "2024-total": 50 }. Quoted keys are also required in JSON, where every key must be double-quoted — a rule that catches beginners moving between JavaScript objects and JSON. A value can be any kind: string, number, boolean, array, another object, or function. That freedom lets objects model complex things, but large literals are best kept tidy with one pair per line.
There are other ways to create objects, and you should recognise them. new Object() creates an empty object, but it is rarely used because {} is shorter and clearer. A constructor function or a class can create many objects with the same shape, which becomes useful when you need dozens of similar objects and want a shared method, but for this chapter, literals are enough. The important distinction, as with arrays, is between an object with properties and an empty object, {}. An empty object is a real object with no properties yet, and programs often start with one and add properties as they are discovered, for example collecting settings from a form. Starting empty and growing is normal, so {} should not surprise you.
A common mistake is confusing an object literal with a code block. In some positions JavaScript reads { as the start of a block of statements, not an object, which is why returning an object literal from an arrow function needs parentheses around it. You do not need to master that edge case now, but if you ever see a strange error from an object in an unusual position, check whether JavaScript is reading your braces as a block. For ordinary assignments like const person = { ... }, there is no ambiguity. Practise by writing objects for things around you — a book, a phone, a lesson — with three or four properties each, and log them. Name keys well: a key should say what the value is, so price beats p, and publishedYear beats year2.
const book = { title: "Dune", pages: 412 };
const empty = {};
console.log(book.title); // "Dune"
console.log(Object.keys(empty).length); // 0
const user = { "first name": "Ada", age: 25 };
console.log(user["first name"]); // "Ada"
Check: In { title: "Dune" }, which part is the key and which part is the value?
Section 24 — Reading properties: dot and bracket notation
Dot notation (person.name) is the normal way to read a property. It is short, clear, and the form you will use most often, because it reads like a sentence: the person's name, the product's price, the user's city. Bracket notation (person["name"]) does the same job, with the key as a string inside brackets, and is needed in two situations where dot notation cannot work. The first is when the key is not a valid identifier: a key with a space, hyphen, or leading digit, like "first name" or "2024-total", cannot follow a dot. The second, more common in real code, is when the key is stored in a variable, so you do not know it when you write the code. Then person[key] looks up whatever the variable holds, while person.key looks up a property literally called "key" — a frequent beginner bug.
That variable case deserves a worked example, because it is where bracket notation earns its keep. Suppose you have a settings object and a variable setting that holds the name of the setting a user just changed, for example "theme". Writing settings[setting] reads the current theme, whatever setting holds, and it keeps working if setting later holds "size" or "language". Writing settings.setting would always read a property called "setting", which probably does not exist, giving undefined and a confusing bug. The rule is simple once stated: if the key is written in the code, use a dot; if the key is in a variable, use brackets. Loops over objects, covered in Section 31, rely on this rule, because the loop variable holds each key in turn, and only bracket notation can use it.
Reading a property that does not exist gives undefined, quietly, just like reading a missing array index. That quietness is convenient for checking whether a property exists — if (person.nickname === undefined) — but it also means a typo in a property name produces undefined instead of an error, and the real error appears later when you try to use that undefined value. When a property reads as undefined unexpectedly, check the spelling first, including capitalisation, because JavaScript property names are case-sensitive: person.Name and person.name are different properties. A second check is whether you meant a variable key but used dot notation, as described above, which is the same symptom with a different cause.
You can also read properties in a chain, as in user.address.city, where each dot descends one level into a nested object. Chaining is clear and common, but it breaks if any level in the middle is missing: reading .city from undefined throws an error. Section 42 introduces optional chaining for that case. For now, remember that a chain is only as safe as its weakest link, and that a missing middle level is the usual cause of "cannot read property of undefined" errors. When you see that error, do not look at the last property in the chain; look at the level before it, and ask whether that object actually exists.
const person = { name: "Ada" };
const key = "name";
console.log(person.name); // "Ada"
console.log(person[key]); // "Ada"
console.log(person.nickname); // undefined — no such property
console.log(person["first name"]); // undefined here, valid syntax for spaced keys
Check: When must you use bracket notation instead of dot notation?
Section 25 — Adding and changing properties
Assigning to a property changes it if it exists, or creates it if it does not. There is no separate "add" operation: the same assignment does both jobs, and JavaScript decides based on whether the key is already present. person.age = 25 on an object without age creates it with value 25; on one that already has age, it replaces the old value. This uniformity is convenient, because you need not check first, but a typo silently creates a new, unwanted property instead of updating the one you meant. Assign person.nmae = "Ada" when you meant name, and you now have both: name unchanged and nmae new. Logging the object after an assignment, while learning, catches this quickly.
Adding properties is how objects grow as a program learns new information. A user object might start with a name from sign-up, gain an age from a profile page, and gain a preferences object from settings, all through simple assignments at different times. That growth is normal, but it can make an object's shape hard to predict if properties are added in many scattered places. A good habit is to create objects with their main properties up front, in the literal, and add only genuinely new, optional information later. That way a reader can see the expected shape in one place, rather than hunting for every assignment that might have added a property.
Changing a property follows the same rule as changing an array item: it changes the object in place, and any other variable pointing at the same object sees the change. That sharing is the reference behaviour of Sections 34 and 39, and it is why updating an object in one function can affect a display in another part of the program. For now, the practical point is that assignment is an edit, not a copy: person.name = "Ada Lovelace" does not create a new person, it edits the existing one. If you need a new object with a changed property and the original untouched, use spread, as Section 33 shows, to build a fresh object with the change applied.
Bracket notation works for assignment too, and it is the right choice when the property name is in a variable, just as it is for reading. person[key] = value sets whichever property key names, which is how generic code — for example, a form handler that updates a field by name — works without knowing the field in advance. Dot notation with a variable name would set a property literally called after the variable, not the property the variable holds, which is the same bug as in reading. The rule carries over unchanged: literal key, use a dot; variable key, use brackets, for both reading and writing.
const person = { name: "Ada" };
person.age = 25;
console.log(person); // { name: "Ada", age: 25 }
person.name = "Ada Lovelace";
const key = "city";
person[key] = "Lagos";
console.log(person.city); // "Lagos"
Check: What happens when you assign to a property that does not exist yet?
Section 26 — Deleting a property
The delete operator removes a property from an object entirely. After delete person.age, the object no longer has an age property at all — it is not set to undefined or to zero or to an empty value, it is gone, so Object.keys(person) will no longer list it, and a for...in loop will no longer visit it. Reading the deleted property afterwards gives undefined, which is the same value you would get from reading a property that never existed, so you cannot tell from the read alone whether a property was deleted or was never there. That distinction rarely matters in practice, but it is worth knowing, because it explains why delete is different from assigning undefined: assignment keeps the key with an empty value, deletion removes the key itself.
When should you delete a property? When information genuinely no longer applies, such as removing a temporary field before sending an object to a server, or cleaning up an optional setting a user has cleared. In many cases, however, deletion is not the best tool. If a property might be needed again, or other code expects the object's shape to stay stable, setting it to a sensible empty value, or building a new object without it using destructuring with rest syntax, is clearer and safer. Deleting from fixed records can also be slower in some engines. The main reason to prefer alternatives is predictability: an object whose keys change over time is harder to reason about than one whose keys stay the same.
There are properties you cannot delete, though you will rarely meet them at this level. Built-in properties of some objects resist deletion, so delete returns false instead of true; in strict mode, trying to delete one throws an error. For ordinary properties you created yourself, it succeeds. Note too that delete only works on object properties, not variables: delete person does nothing useful, and deleting an array item leaves an empty hole rather than shortening the array, which is why arrays have their own removal methods. Use delete for object properties, and pop(), shift(), or splice() for array items.
A practical example helps fix the idea. Imagine a draft post object with a draftNote property used only while editing. Before publishing, you might delete that internal note so it is not sent with the post. That is a legitimate, purposeful deletion: the note was temporary, its job is done, and the published object should not carry it. Contrast that with deleting a user's email because it is temporarily empty — that would be wrong, because the field still exists in the record, it just has no value right now, and deleting it would confuse code that expects an email field. The question to ask is: does this property no longer belong on this object at all, or does it belong but currently have no value? Only the first case calls for delete.
const person = { name: "Ada", age: 25 };
delete person.age;
console.log(person); // { name: "Ada" }
console.log(person.age); // undefined
console.log("age" in person); // false — the key is gone
person.nickname = undefined;
console.log("nickname" in person); // true — key kept, value undefined
Check: After delete person.age, what does person.age return?
Section 27 — Methods: functions inside objects
A function stored as an object property is called a method. Methods let an object carry its own behaviour along with its data, so a person object can not only hold a name but also know how to introduce itself. You write a method like any other property, except the value is a function: greet: function () { return "Hello, I am " + this.name; }. Calling it looks like person.greet(), with parentheses, because you are invoking a function, and the dot before it says "the greet function that belongs to this person". Without the parentheses you would get the function itself, not its result, which is a common slip when you are used to reading plain properties.
Inside a method, this refers to the object the method belongs to — more precisely, the object before the dot at the call site. In person.greet(), this is person, so this.name reads person.name. That is why the same method can work for different objects: if you copy it onto another person object, this adapts to whichever object called it. This flexibility is powerful, but this is not fixed when the method is written; it is decided when the method is called. If you store a method in a separate variable and call it without an object, this will not be the original object, and the method will fail or read the wrong data. That detached-method bug is one of JavaScript's most famous pitfalls, so usually call methods directly on their objects, as person.greet().
Methods are especially useful when an object has behaviour depending on its own data. A bank account might have a deposit method adding to its own balance, a cart a total method summing its own items, and a user a greet method using its own name. Putting behaviour on the object keeps data and the logic using it together, so a reader need not hunt for a separate function that happens to know the object's shape. That grouping is the beginning of object-oriented thinking, though this book does not require mastering that whole style. For now, it is enough to recognise methods, write simple ones, and understand this in the direct-call case, which covers most front-end uses, such as objects formatting their own display text or validating their own fields.
Modern JavaScript also offers a shorthand, where greet() { ... } inside a literal means the same as greet: function () { ... }. You will see both forms in real code, and they behave the same for this chapter, except for details around this in arrow functions, which should not be used as methods because arrow functions do not get their own this. A safe rule is: write methods with the traditional function form or the shorthand, never with an arrow function, if the method needs this. That prevents confusing bugs where this.name is unexpectedly undefined inside what looked like an ordinary method.
const person = {
name: "Ada",
greet: function () { return "Hello, I am " + this.name; }
};
console.log(person.greet()); // "Hello, I am Ada"
const other = { name: "Bola", greet: person.greet };
console.log(other.greet()); // "Hello, I am Bola" — this follows the caller
Check: Inside a method, what does this refer to?
Section 28 — Arrays or objects: which to use
Use an array when order matters and the items are the same kind of thing, like a list of scores, names, or tasks in the order they should be done. Use an object when each piece has a name and a different meaning, like a user's name, age, and city, where swapping two values would change the meaning entirely. That single test — "do the parts have names, or only positions?" — decides most cases. A shopping list is an array because it is a sequence of items in the order you will buy them. A shopping item itself, with name, quantity, and price, is an object, because those facts mean different things and need labels.
The test also helps with borderline cases. Coordinates could be an array [10, 20] or an object { x: 10, y: 20 }. The array is shorter, but the object is clearer, because a reader need not remember whether position 0 is x or y. For a small, widely understood pair, either is acceptable, but for anything a reader might misread, the object wins on clarity, even at the cost of a few extra characters. Similarly, settings with distinct names form an object, not an array of values in a remembered order, because the names are the point. Conversely, favourite colours form an array, because the colours are interchangeable items, not distinct fields.
Another way to decide is to ask what operations you will perform. If you will loop over all the items, count, filter, sort, or display them as a list, an array's tools fit naturally. If you will look up one specific piece by name, check whether a named field exists, or pass a bundle of related values to a function, an object's tools fit naturally. Often you will need both, which is why arrays of objects are so common: the outer array gives list operations, and each inner object gives named fields. A list of products is an array because you will filter and display it as a list, and each product is an object because you will read its name, price, and stock by name.
Finally, consider growth. Adding a new item to an array is easy and does not disturb existing items. Adding a new property to an object is also easy, and does not disturb existing properties by name. But adding a new field to an array-based record disturbs every position after it, as Section 22 warned, while adding a property to an object disturbs nothing. That robustness is another reason objects are preferred for records that may gain fields over time, such as a user profile starting with a name and later gaining a bio, location, and picture. Design for the data you expect in six months, not just today, and you will usually choose objects for records and arrays for lists.
const shoppingList = ["milk", "bread", "eggs"]; // list: array
const shoppingItem = { name: "milk", quantity: 2, price: 800 }; // record: object
const items = [
{ name: "milk", quantity: 2 },
{ name: "bread", quantity: 1 }
]; // list of records: both
Check: You need to store a shopping list in order. Array or object, and why?
Section 29 — Objects inside objects
Objects can hold other objects, letting you model real structure: a user object can hold an address object, which holds a street and a city, mirroring how addresses are structured in real life. You chain dots to reach inner values, as in user.address.city, reading left to right from the outer object inward. Each dot descends one level, and the chain can be as long as nesting requires, though very long chains suggest the data might be better flattened or a helper variable would help. A useful habit is to store an intermediate level in a named variable — const address = user.address; — then read address.city, which is shorter and gives you a place to check that the intermediate object exists.
Nesting appears everywhere in real data. A blog post might hold an author object, with a name and profile picture. An order might hold a customer object and a list of item objects. A settings object might hold a theme object and a notifications object, each with their own properties. In each case nesting reflects a "has a" relationship: a post has an author, an order has a customer, settings have a theme. Recognising that relationship helps you design data: when one thing naturally contains another, nest it, rather than flattening everything with long, prefixed keys like authorName and authorPicture. Flattening works for small cases, but it loses the grouping, and makes it harder to pass just the inner part to a function that only needs the author.
The main risk with nesting is a missing middle level. If user.address is undefined — because the user has not provided an address yet — then user.address.city throws an error, because you cannot read a property of undefined. This is the most common nested-object bug, appearing whenever data is incomplete, optional, or still loading. The defences are checking the middle level first, using optional chaining (user.address?.city, Section 42), or giving defaults when creating the object, so the nested object always exists even if its properties are empty. Optional chaining is best for reading data you do not control; defaults are best for data you create. Either way, ask for every chain, "could any level here be missing?" and handle that case deliberately.
Nesting also interacts with copying. A spread copy of an outer object shares its inner objects, as Section 34 explains, so editing a nested property through the copy edits the original's nested object too. That is not a reason to avoid nesting — it is essential — but be careful when copying a nested structure and then editing the copy. If you need a truly independent copy, you must copy each level, or use a deep-copy utility, knowingly, not by assuming spread copied everything. For reading and displaying nested data, the most common use, none of this matters: reading through a chain is safe as long as the levels exist.
const user = {
name: "Ada",
address: { city: "Lagos", street: "Allen Avenue" }
};
console.log(user.address.city); // "Lagos"
const address = user.address;
console.log(address.street); // "Allen Avenue"
console.log(user.address?.city); // "Lagos" — safe if address exists
Check: How do you read the city from the user object above?
Section 30 — Object.keys, Object.values and Object.entries
These three helpers turn an object into an array, which is useful because arrays have all the looping, filtering, and mapping tools that objects lack. Object.keys() gives the keys as an array of strings, Object.values() gives the values in the same order, and Object.entries() gives pairs, each pair being a small array of [key, value]. The order is the order the properties were added, for string keys, with a few technical exceptions for integer-like keys that you can ignore for now. The key point is that these helpers bridge the two worlds: they let you use array tools on object data, by converting the object into a list first, processing the list, and then, if needed, building a new object from the result.
Object.keys() is the most common of the three, because counting and looping over keys answers questions like "how many properties does this object have?" and "which fields did the user fill in?" An object has no length property, so Object.keys(obj).length is the standard way to count its properties, and to test whether it is empty, as Section 47 explains. It is also the basis for looping over an object with array methods: Object.keys(person).forEach(...) visits each key, and inside the callback you can read the value with bracket notation. That pattern is often clearer than for...in, because it uses the familiar array tools and avoids the inherited-property issues that for...in can raise, though those issues are rare in simple code.
Object.values() is useful when the keys do not matter for the job at hand, only the values. Summing all the scores in a scores object, checking whether any value matches a condition, or displaying all the values as a list are Object.values() jobs. Object.entries() is useful when you need both the key and the value together, for example to build a list of "key: value" display strings, or to transform an object by mapping over its entries and building a new object from the transformed pairs. That last pattern — entries, map, then Object.fromEntries() to rebuild — is a powerful way to transform objects, and it mirrors the array pipeline of map and filter, applied to object data. You do not need to master it now, but recognising entries() as "the object as a list of pairs" prepares you for it.
A worked example makes the three helpers concrete. Take a scores object like { maths: 80, english: 90 }. Object.keys() gives ["maths", "english"], Object.values() gives [80, 90], and Object.entries() gives [["maths", 80], ["english", 90]]. Notice that the values array alone has lost the subject names, so summing it is fine but labelling the results is not, while the entries array keeps both pieces together. Choosing the helper is therefore a choice about what information you need to keep: keys only, values only, or pairs. When in doubt, start with entries(), because it loses nothing, and you can ignore the part you do not need.
const person = { name: "Ada", age: 25 };
console.log(Object.keys(person)); // ["name", "age"]
console.log(Object.values(person)); // ["Ada", 25]
console.log(Object.entries(person)); // [["name", "Ada"], ["age", 25]]
console.log(Object.keys(person).length); // 2
Check: What does Object.entries() return that Object.keys() does not?
Section 31 — Looping over an object with for...in
for...in loops over the keys of an object, one at a time. Its shape is for (const key in person), which reads as "for each key in person, do this", and on each pass key holds a different property name as a string. Inside the loop you use bracket notation with that key to read the value, as in person[key], because the key is in a variable, and Section 24 established that variable keys need brackets. Dot notation would fail here: person.key would look for a property literally called "key" on every pass, giving undefined repeatedly, which is a bug that looks puzzling until you remember the variable-key rule. The pairing of for...in with bracket notation is therefore a fixed pattern to learn as a unit, not as two separate ideas.
for...in visits keys in the order they were added, for ordinary string keys, making its output predictable for objects you created yourself. It also visits inherited properties — properties from an object's prototype chain — though for plain literal objects there are usually no enumerable inherited properties to worry about, so this rarely bites in beginner code. If you ever need to be strict, guard with Object.hasOwn() or hasOwnProperty() to check a key belongs to the object itself, but most code in this book's style does not need that guard. What you must know is that for...in is for objects, while for...of is for arrays and other iterables: for...of on an object throws an error, and for...in on an array gives indexes as strings, not values. Remember: "in for objects, of for lists".
A common use of for...in is displaying or checking an object's contents generically, without knowing its keys in advance. A settings page might loop over a settings object and print each setting and value, or a debug function might log every property of an object it is given. In those generic cases, for...in is right because the code cannot name the keys in advance. When you do know the keys, direct access is clearer: person.name says more than a loop that happens to visit "name" among others. So the choice is specific access for known fields versus generic looping for unknown or variable fields. Modern code often prefers Object.keys() plus forEach() for the same job, but for...in remains standard and readable, especially in older code.
Practise for...in by looping over a small object and logging both key and value each pass, as in "name is Ada". Notice the key is a string, even when its value is a number, and the order matches the order you wrote the properties. Then try the same loop with dot notation by mistake, and see the repeated undefined in the console. That deliberate mistake, seen once, teaches the bracket rule more firmly than reading, and helps you recognise the same bug later as a list of blank values.
const person = { name: "Ada", age: 25 };
for (const key in person) {
console.log(key + " is " + person[key]);
}
// "name is Ada", "age is 25"
for (const key in person) {
console.log(person.key); // undefined — wrong: looks for a property called "key"
}
Check: Why do you need bracket notation inside a for...in loop?
Section 32 — Destructuring an object
Object destructuring pulls named properties into variables in one line. Writing const { name, age } = person creates two variables, name holding person.name and age holding person.age, without you having to write the object name twice. Unlike array destructuring, the names matter, not the order: { age, name } gives the same variables as { name, age }, because each variable is matched to a property by its name. That name-matching is the whole point, and it is why object destructuring is so readable: the line itself shows which properties you need, and the variables are named after them, so a reader knows exactly what data the following code will use.
Destructuring is most useful when a function receives an object argument, or when you need a few properties from a larger object repeatedly. Instead of writing person.name five times in a function, you destructure once at the top and then use the short variable name throughout. That is shorter, but more importantly it documents the function's needs: the destructuring line is a list of the properties the function actually uses, so a reader can see at a glance what data it depends on. It also works in function parameters themselves, as in function greet({ name }) { ... }, which unpacks the argument as it arrives. That parameter form is common in real code, especially in frameworks, so recognising it is important even if you do not write it often at first.
You can rename a property as you destructure it, with a colon, as in const { name: fullName } = person, creating a variable fullName holding the name property. Renaming helps when the property name would clash with another variable, or when a short property name needs a clearer variable name in context. You can also give defaults, as in const { nickname = "Guest" } = person, which apply when the property is missing or undefined, just as with array destructuring. Defaults are a clean way to handle optional properties, and pair well with the techniques in Section 43. Note: a property that exists but holds null does not trigger a default, because defaults only apply to undefined.
As with array destructuring, moderation matters. Destructuring two or three properties you use often is clearer than repeating the object name; destructuring ten at the top of a function, most used once, can obscure where values came from. A reader seeing a bare city later in a long function may have forgotten it came from a destructuring line at the top, while user.address.city would have reminded them. Guideline: destructure when variables are used close to the destructuring line, or when the function's purpose is defined by those properties, and prefer full property access when the source object matters for understanding.
const person = { name: "Ada", age: 25 };
const { name, age } = person;
console.log(name); // "Ada"
const { name: fullName, nickname = "Guest" } = person;
console.log(fullName); // "Ada"
console.log(nickname); // "Guest"
function greet({ name }) { return "Hello, " + name; }
console.log(greet(person)); // "Hello, Ada"
Check: In object destructuring, what matters — the order or the names?
Section 33 — Merging objects with spread
Spread also works for objects: it copies properties into a new object, and later properties overwrite earlier ones with the same key. Writing { ...defaults, theme: "dark" } starts with all of defaults' properties, then sets theme to "dark", overriding the default. That "later wins" rule is the whole mechanism: list sources in order of increasing priority, and the last source to mention a key decides its value. Merging defaults with user settings is therefore a one-liner — { ...defaults, ...userSettings } — where any setting the user provided wins, and any they did not keeps its default. That pattern is used everywhere settings, options, and configurations are handled.
The merged object is new: changing it does not change the sources, and changing the sources afterwards does not change the merged object, at least at the top level. That independence is the same shallow-copy behaviour as arrays, and the same caveat applies to nested objects: if a property holds an object, the merged object shares that inner object with the source, so editing it through the merged copy edits it in the source too. For flat settings objects, where values are strings, numbers, and booleans, that caveat does not matter, and spread merging is safe. For nested settings, you may need to merge each level separately, or accept the sharing, depending on whether inner objects will be edited.
Spread merging also lets you add, remove, and update properties without mutating. Adding is { ...person, city: "Lagos" }; updating is the same syntax, because later wins; removing can be done by destructuring out the unwanted property and spreading the rest, as in const { age, ...rest } = person, where rest is a new object without age. That non-mutating style — always building a new object instead of editing the old one — is popular in modern front-end code, especially state management, because it makes changes explicit and traceable: the old object remains, unchanged, if you need to compare or undo. It is not always right — editing in place is simpler for local, short-lived data — but for shared state, the new-object style prevents bugs where one part edits data another part was relying on.
Order is the detail to get right. { ...userSettings, ...defaults } looks similar to the correct merge, but it does the opposite: defaults win, and user choices are lost, because defaults come later. When a merge behaves backwards — user settings seem ignored, or defaults seem to override everything — check the order first. A helpful way to remember it is to read the spread list as a story in time: first the defaults were set, then the user changed some of them, so the user's version is the latest and therefore wins. Time order and spread order should match, and when they do, the result is what a user would expect.
const defaults = { theme: "light", size: 16 };
const settings = { ...defaults, theme: "dark" };
console.log(settings); // { theme: "dark", size: 16 }
const userSettings = { size: 20 };
console.log({ ...defaults, ...userSettings }); // { theme: "light", size: 20 }
Check: In the example, why is the theme "dark" and not "light"?
Section 34 — Copying objects is shallow
Spread copies only the top level. If a property holds another object, both copies still point to that same inner object, so changing it through one copy changes it for the other. This is a shallow copy: copying goes one level deep, like copying a folder's cover but sharing the papers inside. Top-level properties are independent — changing second.name does not change first.name, and adding a top-level property to one does not add it to the other — but inner objects are shared, because only their references were copied. That sharing is invisible until you edit a nested property, when the original changes "by itself", one of JavaScript's most confusing beginner bugs, because the code that changed the original is nowhere near its creation.
Why does JavaScript copy this way? Because a deep copy — copying every level, recursively — is more expensive and raises questions: what about functions, circular references where an object refers to itself, and special object types? A shallow copy is fast, predictable, and sufficient for the most common case, which is flat objects with simple values. The language therefore gives you a shallow copy by default, through spread and Object.assign(), and leaves deep copying to you when you need it, through a utility or through structuredClone(), a built-in function in modern browsers that deep-copies most plain data. Knowing that the default is shallow means you will not be surprised, and you will reach for a deep copy deliberately when the data is nested and the copy must be independent.
The practical rule is to match copy depth to edit depth. If you will only change top-level properties of the copy, a shallow copy is enough. If you will change nested properties, you must copy the nested levels you will change, for example { ...first, address: { ...first.address, city: "Abuja" } }, which copies the outer object and the address object with the changed city, leaving the original address untouched. That nested-spread pattern is verbose, but explicit about which levels are new and which are shared, and it is the standard technique for updating nested state without mutating the original. For deeply nested data, a deep-copy helper is cleaner, but for one or two levels, nested spread is clear.
Try the shallow-copy surprise in the console, deliberately, so you recognise it later. Copy an object with a nested address, change the copy's nested city, and log the original's city. Seeing it change is the lesson, and it is better to see it in a controlled experiment than in a real program where an address changes in two places at once. Then fix it with a nested spread and confirm the original stays put. That pair of experiments teaches both problem and solution, and gives you a check to run whenever you copy an object: "is this data nested, and will I edit the nested part?" If both answers are yes, copy the nested part too.
const first = { address: { city: "Lagos" } };
const second = { ...first };
second.address.city = "Abuja";
console.log(first.address.city); // "Abuja" — shared inner object
const third = { ...first, address: { ...first.address, city: "Enugu" } };
console.log(first.address.city); // still "Abuja" — third copied the inner level
console.log(third.address.city); // "Enugu"
Check: Why did changing the copy also change the original in this example?
Section 35 — Arrays of objects: the most common data shape
Real data usually arrives as an array of objects: a list of users, products, or posts, where each item is an object with the same keys. This shape combines both containers' strengths: the array gives order, counting, filtering, mapping, and sorting, and each object gives named fields you can read by name. A list of users is an array because you will display, search, and count it; each user is an object because you will read its name, age, and city by name. Neither container alone would do both jobs well, and together they cover almost all front-end data, from a shop's catalogue to a blog's posts to a chat's messages.
Working with an array of objects means combining the skills of the two halves of this chapter. Reading the second user's name is users[1].name: first index into the array, then read the property, in that order, left to right. Filtering the list for adults is users.filter(u => u.age >= 18), where the filter test reads a property of each object. Mapping the list to names is users.map(u => u.name), where the map function extracts one property from each object. Sorting by age is a compare function that subtracts one object's age from another's. Each of these is a small combination of two familiar ideas, and none requires a new concept, which is why this chapter taught arrays and objects separately first: the combination is just the two skills used together, in sequence.
Consistency of shape matters enormously. If every user has name and age, code reading those properties works for every item. If one is missing age, spells it differently, or holds it as a string, filtering, sorting, and displaying break for that item, often with a confusing error or blank space. Real data is not always consistent — a server may omit a field, or an older record may predate a new field — so defensive habits like optional chaining and defaults, from later sections, are part of working with this shape. When you create such data, decide the keys once, use them for every item, and add no one-off keys, because consuming code assumes uniformity.
Displaying an array of objects is the bridge to the next chapter: loop over the array, and for each object create page elements filled with its properties, as in a card for each product with its name and price. That render pattern turns data into a visible list, the core job of most front-end pages. For now, practise the data side: create five objects of one kind, read a property from a specific item, filter by a property, map to a simpler list, and sort by a numeric property. If you can do those four confidently, you can handle the data behind almost any list page, and the DOM chapter will only need to teach the display half.
const users = [
{ name: "Ada", age: 25 },
{ name: "Bola", age: 30 }
];
console.log(users[1].name); // "Bola"
console.log(users.filter(function (u) { return u.age >= 28; }));
console.log(users.map(function (u) { return u.name; })); // ["Ada", "Bola"]
Check: How do you read the name of the second user in the array above?
Section 36 — What JSON is
JSON, short for JavaScript Object Notation, is a text format for sending data, based on JavaScript object and array syntax. It looks like an object, but it is a string — a sequence of characters that can be stored in a file, sent over a network, or saved in a browser's storage — and only becomes a usable object when it is parsed. That distinction, between the text and the thing it describes, is the key to understanding JSON. A JSON document describing a person might look exactly like a JavaScript object literal, but as long as it is JSON, it is text, and you cannot read its properties with dot notation until you parse it. Parsing turns the text into a real object in memory, and stringifying turns a real object back into text for sending or storing.
JSON's rules are stricter than JavaScript's literal rules, and the differences trip up beginners. In JSON, every key must be double-quoted, not bare or single-quoted, and string values must also use double quotes. There are no functions, no undefined, no comments, and no trailing commas, because JSON is a data format, not a programming language, representing only strings, numbers, booleans, null, arrays, and objects of those. undefined has no JSON equivalent; it is omitted or replaced by null, meaning "intentionally no value", distinct from undefined meaning "no value assigned". Those strict rules make JSON reliable across languages: a Python server's document can be read by a JavaScript browser, because both follow the same narrow format.
Why does JSON matter to a front-end developer? Because almost every interaction with a server uses it. When a page asks a server for a list of products, the server usually replies with JSON text, and the page parses it into an array of objects it can display. When a page sends a form to a server, it usually stringifies an object into JSON text for the request. Browser storage, configuration files, and many APIs also use JSON, so it is the common language of data exchange on the web. You do not need to memorise its grammar in full — a JSON validator or a parse error will tell you when a document is malformed — but you should recognise valid JSON on sight, know its restrictions, and know that it is text until parsed.
Think of JSON as a photograph of an object. A photograph looks like the person, but you cannot talk to it, and it captures only what was visible when taken. Similarly, JSON captures an object's data at stringifying, as text, but not its methods, which are behaviour, not data, and are dropped. Parsing later gives a fresh object with the same data, but without the original's methods, and without sharing its references. That snapshot behaviour is sometimes used as a quick deep copy for plain data, though structuredClone() is now cleaner. For ordinary work, remember the cycle: object in memory, stringify to text for travel or storage, parse back on arrival, never use the text as if it were already an object.
const text = '{"name": "Ada", "age": 25}';
console.log(typeof text); // "string"
console.log(JSON.parse(text).name); // "Ada"
const data = { items: [1, 2], active: true, note: null };
console.log(JSON.stringify(data)); // '{"items":[1,2],"active":true,"note":null}'
Check: Is JSON an object, or a string that looks like one?
Section 37 — Converting between objects and JSON
JSON.stringify() turns an object into a JSON string, and JSON.parse() turns a JSON string back into an object: a round trip, object to text to object again. The round trip is not perfectly lossless, and knowing what is lost prevents surprises. Functions are dropped, because JSON cannot represent code as data. undefined values in objects are dropped, and undefined in arrays becomes null. Dates become strings, because JSON has no date type, so a parsed date must be converted back into a Date object if you need date behaviour. Special numbers like Infinity and NaN become null. None of this is a flaw; it is the price of a simple, cross-language format, so stringify only plain data and handle dates and functions separately.
JSON.parse() throws an error on invalid JSON, which is a different behaviour from the quiet undefined of a missing property. If a server returns an error page in HTML instead of JSON, or if a stored string was truncated, parsing will fail loudly, and that failure will stop your script unless you handle it. The standard defence is a try...catch around the parse, with a fallback for the failure case, such as showing an error message or using default data. For data you control, such as an object you just stringified yourself, parsing cannot fail, because stringify only produces valid JSON, so the try-catch is mainly for data from outside your program. Learning to wrap outside parses in try-catch is a small habit that prevents a whole class of crashes from bad or unexpected responses.
JSON.stringify() accepts optional arguments that are useful in real work. A second argument can filter which properties are included, and a third argument adds indentation for readable output, as in JSON.stringify(obj, null, 2), which produces a nicely indented multi-line string instead of a compact single line. The indented form is excellent for debugging, for configuration files a human will read, and for logging, because it shows the structure clearly. The compact form is better for sending over a network, because it is smaller. Both are valid JSON and parse to the same object, so the choice is about readability versus size, not correctness. Try both in the console on the same object and compare: the data is identical, only the presentation differs.
A common mistake is parsing something already an object, or stringifying something already a string. Parsing an object throws an error, because JSON.parse() expects a string, and stringifying a string wraps it in extra quotes, so parsing it returns the original string, not an object. When a conversion gives a strange result, check the type first with typeof: if already an object, do not parse it; if already a string, do not stringify it unless you deliberately want a quoted string. That check resolves most JSON confusion, because JSON errors are almost always type errors in disguise — text treated as an object, or an object treated as text.
const person = { name: "Ada", age: 25 };
const text = JSON.stringify(person);
console.log(text); // '{"name":"Ada","age":25}'
console.log(JSON.parse(text).name); // "Ada"
console.log(JSON.stringify(person, null, 2));
try {
console.log(JSON.parse("{bad json"));
} catch (error) {
console.log("Parse failed:", error.message);
}
Check: Which method turns an object into a JSON string, and which reverses it?
Section 38 — Passing objects to functions
When you pass an object to a function, the function receives the same object, not a copy. The parameter inside the function points at the very same object in memory as the variable outside, so changing a property inside the function changes the original object outside it. This is the reference behaviour again, now in the context of function calls, and it is worth stating plainly because it contradicts the intuition built from numbers and strings, where passing a value gives the function its own copy to work with. With objects and arrays, there is no copy: there is one object, with two names for it during the call — the outside variable and the inside parameter — and edits through either name affect the one shared object.
That sharing can be a feature or a bug. It is a feature when a function's job is to update an object, such as birthday() incrementing an age, or a function marking a task complete: the caller expects the change, and the function delivers it without returning anything. It is a bug when a function meant only to read the object, or work on a draft, accidentally edits the original, so a change in one part appears in another that did not expect it. Decide, before writing a function, whether it should mutate its argument, and document that in its name or a comment. updateUser() is expected to mutate; formatUser() is expected to return a new value without mutating, and mutating there is a surprise.
Reassigning the parameter does not affect the outside variable, which is a subtle but important distinction. If a function does person = { name: "Someone else" }, it only changes what the local parameter points at; the outside variable still points at the original object. What affects the outside object is changing a property of the parameter's object, as in person.age = 26, because that edits the shared object itself, not the pointer to it. Understanding the difference between reassigning a variable and mutating the thing it points at is one of the deeper ideas in JavaScript, and it explains many behaviours that otherwise look inconsistent: why one kind of change inside a function leaks out, and another kind does not.
For functions that should not mutate, build and return a new object with spread, as in return { ...person, age: person.age + 1 }. The caller can then choose whether to replace the old object, which remains unchanged for other code holding it. That is slightly more work than mutating in place, but it makes data flow explicit and testable: the function takes an object and returns an object, with no hidden side effects. Practise both styles deliberately — a mutating updater and a non-mutating transformer for the same job — and notice how the call sites differ. That comparison teaches when each style is appropriate, a judgement professional developers make daily.
function birthday(person) { person.age = person.age + 1; }
const user = { age: 25 };
birthday(user);
console.log(user.age); // 26 — the same object was changed
function nextBirthday(person) {
return { ...person, age: person.age + 1 };
}
const older = nextBirthday(user);
console.log(user.age); // 26 — unchanged by the second call
console.log(older.age); // 27
Check: After calling birthday(user), why is user.age now 26?
Section 39 — Comparing objects checks identity, not contents
Two objects with identical contents are not equal with ===, because === checks whether they are the very same object in memory, not whether they look alike. Writing { a: 1 } === { a: 1 } creates two separate objects, each holding a: 1, and asks whether they are the same object. They are not, so the answer is false, even though a human looking at the two literals would say they are the same. The comparison is about identity — "is this the one and only object I already have?" — not about equality of contents. Only when both sides point at the very same object, as in same === same, or when one variable was assigned from another without copying, is the answer true.
This follows directly from references. Numbers and strings are compared by value, because each variable holds the value itself, so 1 === 1 and "Ada" === "Ada" are true regardless of where the values came from. Objects are compared by reference, because each variable holds a pointer to an object, and === compares the pointers. Two pointers to the same object are equal; two pointers to different objects, even identical ones, are not. That is why assigning an object to a second variable and comparing them gives true — still one object, two pointers — while building a second, identical object gives false. The rule is consistent once you see it as pointer comparison, but it surprises everyone first time, because it breaks the everyday meaning of "equal".
The practical consequence is that you cannot test same data with ===. To compare contents, compare the properties you care about, one by one, or write a deep-equality function walking both objects and comparing every property, recursing into nested objects. For many jobs, one identifying property is enough: two users are the same user if their id matches, regardless of other fields, and two products are the same if their code matches. Identifier comparison is simpler, faster, and often more correct than a full deep comparison, because it matches how the real world identifies things. For a full comparison, be explicit, and consider a library or JSON-string comparison for simple, flat data — noting that JSON comparison depends on key order and drops functions, so it is not general.
The same rule explains why includes() and indexOf() fail to find an equal-looking object, as Section 16 noted, and why find() by property is the right tool for object searches. Build this habit: when you write === between objects, ask whether you mean "the same object" or "the same data". If the latter, === is the wrong tool — use a property comparison. That question prevents bugs where a check that "should" pass keeps failing.
console.log({ a: 1 } === { a: 1 }); // false — two objects
const same = { a: 1 };
const alias = same;
console.log(same === alias); // true — one object, two names
console.log(same.a === alias.a); // true — comparing a property value
const copy = { ...same };
console.log(same === copy); // false — a new object, same contents
Check: Why is { a: 1 } === { a: 1 } false?
Section 40 — const does not freeze an array or object
Declaring an array or object with const stops you reassigning the variable itself, but you can still change its contents — push items, pop items, or change properties. const protects the binding, which is the link between the variable name and the value, not the value itself. Think of the variable as a label tied to a box: const says the label cannot be moved to a different box, but it says nothing about what you put into or take out of the box. That is why fruits.push("banana") is allowed on a const array, while fruits = ["pear"] is not: the first edits the box's contents, the second tries to move the label to a new box, which const forbids, throwing an error in the process.
This confuses beginners because "constant" suggests unchanging. In JavaScript, const for a primitive like a number or string does behave like a true constant, because the value is held directly with nothing inside to edit. For an array or object, the value held is a reference to a container, separate from its contents, so const only freezes the reference. If you need a truly unchanging array or object, use Object.freeze(), which prevents adding, removing, or changing top-level properties, though it too is shallow and does not freeze nested objects. It suits configuration data that should never change, but it is not the default, and most code does not use it, because most data is meant to change.
Use const by default for arrays and objects, and let only when you genuinely intend to reassign the variable to a different array or object later. Most array and object work edits the contents, not the binding, so const covers the common case and still catches accidental reassignment. A loop building a list, for example, declares the list with const and pushes into it, which reads naturally once you know const allows content changes. Code reassigning a list variable inside a loop, or an object variable to a new object each iteration, usually signals a different design, such as building a new list with map() instead of mutating, and deserves a second look.
When you see an error saying "assignment to constant variable", check which kind of assignment it is. If the code tried to point the variable at a new array or object, the error is correct and the fix is to use let, or to restructure the code to edit the existing container instead. If the code only edited contents, it should not have triggered the error, so something else is wrong, such as a shadowed variable or a typo in the variable name. That error message is actually helpful: it tells you the code attempted a rebinding, which narrows down what went wrong. Practise the distinction in the console: declare a const array, push to it successfully, then try to reassign it and watch the error, so that both behaviours are familiar rather than theoretical.
const fruits = ["apple"];
fruits.push("banana"); // allowed — contents change
console.log(fruits); // ["apple", "banana"]
// fruits = ["pear"]; // TypeError: Assignment to constant variable.
const frozen = Object.freeze({ name: "Ada" });
// frozen.name = "Bola"; // silently ignored, or an error in strict mode
console.log(frozen.name); // "Ada"
Check: What does const prevent for an array, and what does it still allow?
Section 41 — Modelling real things
Good data design starts by naming the thing and listing its properties: a product has a name, price, and stock count; an order has items and a total; a lesson has a title, duration, and questions. Done before any code, that step forces you to decide what the thing is and what facts matter for the job. A shop product needs price and stock, a browsing catalogue product might also need description and picture, and a warehouse product might need shelf location instead. The same thing gets different properties in different programs, because each cares about different facts, and a good model includes exactly the facts its program needs — no more, no fewer.
Start by writing a sentence about the thing, then underlining the nouns that become properties. "A blog post has a title, an author, a body, a published date, and a list of comments" gives five properties directly, and the comments become an array of comment objects, each with author and body. Practise that translation on anything: a song, a recipe, a football match, a bus timetable. Each becomes an object, or an array of objects, with properties a reader can guess from the sentence. The test of a good model is whether another developer, reading only your object, understands what it represents. Clear names are half that test; consistent types are the other half, because a price sometimes a number and sometimes a string breaks calculations.
Decide a type for each property. A price should be a number, so it can be summed and compared, not a string with a currency symbol needing parsing before every calculation. A date should be a standard string or a Date object, consistently, not sometimes one and sometimes the other. A boolean like inStock should be true or false, not "yes" and "no", because boolean logic is clearer than string comparison. A list should be an array, even when it holds one item or none, because code expecting an array breaks on a single bare value. Making these decisions up front, and writing them down if the model is shared, saves far more time than it costs.
Finally, model for the operations you will perform, not just the data you have. If you will sort by price, price must be a number. If you will filter posts by author, author must be a consistent identifier, not sometimes a name and sometimes an object. If you will display a list, it must be an array, even if it starts empty. This operations-first thinking is why earlier sections matter: arrays give list operations, objects give named access, and the combination gives both. A model supporting its operations cleanly is good, even if it differs slightly from reality, because a program's model is a tool for a job, not a perfect copy. Practise on three things from your life: list properties and types, then write one operation it must support. If that is awkward, adjust the model.
const product = { name: "Notebook", price: 1500, inStock: true };
const post = {
title: "Learning Arrays",
author: "Ada",
published: "2026-10-29",
comments: [{ author: "Bola", body: "Helpful!" }]
};
console.log(post.comments[0].author); // "Bola"
console.log(product.price * 2); // 3000 — price is a number, so maths works
Check: Name three properties you would give an object modelling a blog post.
Section 42 — Optional chaining for missing data
Optional chaining (?.) reads a property only if the thing before it exists; otherwise it gives undefined instead of crashing. In user.address?.city, JavaScript first checks whether user.address exists. If it does, it reads .city normally. If it does not — if it is null or undefined — the whole expression short-circuits to undefined, without attempting the read that would have thrown an error. That short-circuiting is the entire mechanism, and it can be chained: user?.address?.city protects both levels, so a missing user and a missing address both give undefined gracefully. Optional chaining also works for method calls, as in user.greet?.(), which calls the method only if it exists, and for bracket access, as in user["address"]?.city, though the dot form is the most common.
The need for optional chaining comes from incomplete data, which is normal in real programs. A user who has not filled in their address, a post whose author was deleted, a product whose details are still loading, or a response from a server that omits an optional field — all of these produce missing middle levels in a property chain. Without optional chaining, code must guard each level manually, as in user.address ? user.address.city : undefined, which is verbose and obscures the actual intent, which is simply "the city, if there is one". With optional chaining, the intent is visible in a single expression, and the guard is built into the syntax. That readability is why optional chaining, introduced in modern JavaScript, was adopted so quickly: it replaces a pattern everyone was already writing by hand with a shorter, clearer form.
There are two cautions. First, optional chaining hides errors as well as expected absences. If user.address is missing because of a bug, it silently gives undefined, and the bug appears later as a blank display, not an immediate error. That is acceptable for genuinely optional data, where absence is expected, but dangerous for data that should always exist, where an error is more helpful than silence. Rule: use optional chaining for data that may legitimately be missing, and direct access for data that must be present, so bugs surface early. Second, it only protects against null and undefined, not other falsy values. If user.address is 0 or an empty string, it will still attempt the read, because those are considered present. Knowing that boundary — nullish only — prevents a false sense of safety.
Practise optional chaining by building a user object with and without an address, and reading the city both ways: directly, which throws for the missing case, and optionally, which gives undefined. Notice the difference in behaviour and in code length, and think about which display you would show for the undefined case — perhaps "No address provided", using a fallback from Section 43. That combination, optional chaining plus a fallback, is the standard pattern for displaying optional data safely: read safely, then display a sensible default when the result is missing, instead of crashing or showing a blank.
const user = {};
console.log(user.address?.city); // undefined, no error
// console.log(user.address.city); // TypeError — address is undefined
const fullUser = { address: { city: "Lagos" } };
console.log(fullUser.address?.city); // "Lagos"
console.log(fullUser?.address?.city ?? "No city"); // "Lagos"
Check: What would user.address.city do here, and why is ?. safer?
Section 43 — Sensible defaults for missing values
When a property may be missing, give a fallback, so later code always has a usable value instead of undefined. Three ways differ in which values they treat as missing. A destructuring default, const { name = "Guest" } = user, applies when the property is undefined. Nullish coalescing, ??, does the same for any expression: user.name ?? "Guest" gives "Guest" only when the left side is null or undefined, otherwise giving it exactly as it is, even if 0, "" or false. Logical OR, ||, is broader: it gives the right side for any falsy left side, including 0, "" and false. That breadth makes || a trap for defaults: a quantity of 0, a valid empty nickname, or a deliberately false setting would all be replaced, incorrectly.
Choosing between ?? and || depends on whether falsy values are valid. For a count, price, or score, 0 is real, so ?? is correct and || is wrong. For a display name, an empty string might mean "no name provided", so || could be intended, but testing explicitly for an empty string is clearer, because the intent is visible. As a default rule, prefer ??, because it only replaces genuine absence, and use || only when you have deliberately decided all falsy values should be replaced. Applied consistently, that rule prevents bugs where a valid zero or false is silently overwritten, even though the code looks correct and the fallback looks sensible.
Defaults also appear in function parameters, as in function greet(name = "Guest"), which applies when the argument is omitted or explicitly undefined. Parameter defaults make a function usable with fewer arguments, and document the default in the signature, where a reader can see it without reading the body. They combine naturally with destructuring defaults for object arguments, as in function showUser({ name = "Guest", age = 0 } = {}), where the = {} handles no argument being passed at all, preventing a destructuring error on undefined. That combined form looks dense at first, but it is a standard pattern for functions with optional object arguments, worth recognising even if you do not write it often yet.
The habit to build is: whenever you read a value that might be missing, decide immediately what should happen in the missing case, and write that decision as a default or a fallback, rather than letting undefined flow downstream. Downstream undefined causes errors in calculations, blank displays, and failed comparisons, all far from the original missing value, making the bug harder to trace. A default at the point of reading localises the decision and keeps the rest of the code simple, because it can assume a real value. Try it with a user object missing a nickname: destructure with a default, use ?? for a fallback display, and compare the result with the unguarded version, which gives undefined and a confusing "Hello, undefined" greeting.
const { name = "Guest" } = {};
console.log(name); // "Guest"
console.log(undefined ?? "fallback"); // "fallback"
console.log(0 ?? "fallback"); // 0 — 0 is not nullish, so it survives
console.log(0 || "fallback"); // "fallback" — 0 is falsy, so || replaces it
console.log(null ?? "fallback"); // "fallback"
Check: What is the difference between ?? and || for a value of 0? (Hint: 0 is not null or undefined.)
Section 44 — Sorting an array of objects
To sort objects, pass sort() a compare function comparing one property, such as price or name. Without one, sorting objects is meaningless, because the default string conversion of an object is "[object Object]" for every item, so all compare equal and the order does not change usefully. The compare function is therefore not optional for objects; it is the sorting logic itself. For numbers, subtract one property from the other, as in function (a, b) { return a.price - b.price; }, giving ascending order by price. For strings, use localeCompare() on the property, as in a.name.localeCompare(b.name), giving alphabetical order. For dates, subtract their time values. In each case, the pattern is the same: pick a property, compare it, and let sort() arrange the objects accordingly.
Sorting by multiple criteria is a natural extension. To sort by category first and then by price within each category, the compare function checks the first criterion, and only if it is equal — returning zero — does it check the second. That chained logic, written as a few lines with early returns or with || between comparisons, is a common real pattern for product lists, leaderboards, and tables that a user can sort by different columns. It is also a good example of why the compare function's contract — negative, positive, or zero — is designed the way it is: zero means "equal on this criterion, try the next one", which makes multi-criteria sorting composable in a way that a simple true-or-false comparison would not be.
Remember that sort() mutates the original, as Section 17 warned. For objects, that is especially consequential, because the objects are shared, and reordering changes the order seen by every part holding that array. If you need a sorted view while preserving the original order — to let a user toggle between "original" and "sorted by price" — copy first with spread and sort the copy, as in [...products].sort(...). The copy shares the objects, which is fine for reordering, because it changes only their sequence, not the objects themselves. That distinction — copying the list versus the items — is the shallow-copy idea applied to sorting, showing why a shallow copy suffices for a sorted view.
Stability is a final detail worth knowing. A stable sort keeps items that compare equal in their original relative order, so sorting a list that is already ordered by date, and then sorting it by category, preserves the date order within each category. Modern JavaScript's sort() is stable, which makes multi-step sorting predictable: you can sort by a secondary criterion first, then by the primary criterion, and the secondary order survives within equal primary groups. That stability is a quiet guarantee that makes chained sorting reliable, and it is worth knowing about, even though you will rarely need to think about it explicitly, because it means your sorts behave sensibly in cases where an unstable sort would scramble equal items arbitrarily.
const products = [{ name: "Pen", price: 500 }, { name: "Book", price: 1500 }];
products.sort(function (a, b) { return a.price - b.price; });
console.log(products[0].name); // "Pen" — cheapest first
const byName = [...products].sort(function (a, b) {
return a.name.localeCompare(b.name);
});
console.log(byName[0].name); // "Book"
Check: How do you sort products from cheapest to most expensive?
Section 45 — Filtering an array of objects
Combining an array of objects with filter() is how real apps build lists like "only in-stock products" or "only posts by one author". The test reads a property of each object, as in function (p) { return p.inStock; }, and the result is a new array holding only objects that passed, in original order, with the original objects shared, not copied. That sharing means the filtered list is a view of the same data, which is usually what you want: editing a product in the filtered list edits the product itself, and a display built from it reflects current data. If you need independent copies, map through a spread copy, but for display and counting, the shared view is correct.
Multiple conditions combine in one test with && and ||, or in a chain of filters, one condition per step. A combined test is shorter, but a chain is often clearer, because each step can be inspected separately. Filtering products first by category, then by stock, then by maximum price gives three small steps, and debugging is easier because you can log the length after each step and see which condition removed the most items. When a filtered list is unexpectedly empty, that inspection finds the culprit quickly: a condition stricter than intended, a misspelled property, or a wrong type, such as a string price compared to a number. Checking one condition at a time, with a known item that should pass, is the fastest diagnostic.
Filtering is also the basis of search and faceted browsing, the filters on a shop page that narrow products by category, brand, price range, and availability. Each facet is a filter condition, and applying several facets is applying several filters, in any order, because filtering is commutative in effect — the final set is the items passing all conditions, regardless of the order you apply them. That insight helps you design filter code: collect the active conditions, apply them as a chain or a combined test, and re-render the result, which is the render pattern of the next chapter. The data does not change; only the view narrows, which is why filtering returns a new array instead of mutating the original, so the full list remains available when the user clears a filter.
A subtle point is the difference between filtering and searching for one item. filter() returns an array, even when one item matches, and that array may be empty. Code taking the first item without checking for emptiness will crash on undefined, just as with find()'s not-found case, but in array form. The habit is to check the filtered array's length before using its items, or to use find() when you expect a single item, as Section 15 advises. Choosing the right method keeps follow-up code natural: loops and counts for filtered arrays, direct access with a null check for found items.
const products = [
{ name: "Pen", inStock: true, price: 500 },
{ name: "Book", inStock: false, price: 1500 }
];
const available = products.filter(function (p) { return p.inStock; });
console.log(available.length); // 1
const cheapAndAvailable = products.filter(function (p) {
return p.inStock && p.price < 1000;
});
console.log(cheapAndAvailable[0].name); // "Pen"
Check: What does the filter test check for each product in this example?
Section 46 — Transforming objects with map
map() over an array of objects can build a new array of simpler values, such as just the names, or of new objects with a changed property. Extracting a property is most common: users.map(u => u.name) turns user objects into names, for a dropdown, summary, or search index. Transforming each object into a new object is equally important: products.map(p => ({ ...p, price: p.price * 2 })) returns new products with doubled prices, leaving the originals untouched. That non-mutating transformation is the safe way to derive a changed list, because the original remains available, and the change is explicit in the returned array rather than hidden as a side effect.
The key discipline is to return a new object when you change a property, rather than mutating the input inside the callback. Mutating inside map() — p.price = p.price * 2; return p; — produces a new array, but holding the same, now-changed objects, so the original's objects have changed too, defeating the purpose of map() returning a new array. A reader seeing map() expects the input unchanged, and violating that causes hard-to-trace bugs, because the change happened where a pure transformation was expected. The spread-then-change pattern, { ...p, price: ... }, makes newness explicit and preserves the input, for a few extra characters well worth it. For extraction, returning a string or number, there is no mutation risk, because you only read the object.
Chaining map() with filter() is a standard pipeline: filter first to keep only wanted items, then map to transform them, as in "in-stock products, mapped to their names". Order matters for efficiency and clarity: filtering first means mapping runs on fewer items, and the pipeline reads as a story — narrow, then transform. Reversing it can also be correct, but transforms items that will then be discarded, which is wasteful and less clear. Pipelines are the functional style of array processing, each step a small operation, the whole readable left to right. Practising on small data, logging after each step, builds intuition for larger processing, where the same pattern scales.
A final caution is the arrow-function object literal trap. When a map() callback uses a concise arrow function to return an object, the literal must be wrapped in parentheses, as in p => ({ ...p }), because otherwise JavaScript reads the braces as a function body, not an object, and the callback returns undefined. That produces an array of undefined values, similar to the forgotten-return bug in Section 13, but with a different cause. A traditional function with an explicit return, or a block-bodied arrow function with return, avoids the trap entirely, and is a good choice while learning, even if the concise form is shorter. Once you recognise the parentheses pattern, you can use the concise form confidently; until then, explicit returns are safer.
const users = [{ name: "Ada" }, { name: "Bola" }];
const names = users.map(function (u) { return u.name; });
console.log(names); // ["Ada", "Bola"]
const products = [{ name: "Pen", price: 500 }];
const doubled = products.map(function (p) {
return { ...p, price: p.price * 2 };
});
console.log(doubled[0].price); // 1000
console.log(products[0].price); // 500 — original untouched
Check: What does the map above return for each user?
Section 47 — Checking whether an array or object is empty
An empty array has length 0, and an empty object has no keys. Test arr.length === 0 for arrays, and Object.keys(obj).length === 0 for objects — never test an array or object directly for truthiness, because even an empty one is truthy. That surprises beginners, because an empty string, zero, and undefined are falsy, so an empty container feels like it should be falsy too. But all objects, including arrays, are truthy regardless of contents, because truthiness for objects is about existence, not fullness: the object exists, so it is truthy, even holding nothing. Hence if ([]) and if ({}) both run their bodies, the opposite of what is often intended when checking for "no results".
The correct tests are explicit. For arrays, arr.length === 0 says "this list holds no items", and arr.length > 0 says "at least one item". For objects, Object.keys(obj).length === 0 says "no properties", though it creates a temporary keys array, which is fine for ordinary sizes. An alternative is a for...in loop returning false on the first key found, avoiding that array, but it is more code for the same result, and the keys-length test is clearer for most purposes. Whichever form you choose, write it in full rather than relying on truthiness, because the explicit test documents intent: a reader should not need to remember the object truthiness rule to understand an emptiness check.
Emptiness checks matter because they control what a user sees. A search with no results should show "No results found", not a blank area or broken list. A settings object with no properties should show defaults, not crash reading a setting. A cart with no items should show an empty-cart message and hide checkout, not a zero total with a failing checkout. In each case, the emptiness test is the branch point between two displays, and getting it wrong — testing truthiness, so the empty branch never runs — means the user sees the wrong display for the empty case, often the first case a new user encounters. Testing the empty case deliberately is therefore an important part of testing any list feature, not an afterthought.
There is also a distinction between empty and missing. An empty array [] means "a list that currently holds nothing", while undefined or null means "no list at all, perhaps because data has not loaded yet". Those two states may need different displays: "No results" for an empty list, versus "Loading..." for a missing list. Conflating them — by treating both as empty, or both as loading — gives the wrong message in one case. The habit is to check for missing first, with if (!results) or optional chaining, and then check for empty, with results.length === 0, in that order, so each state gets its own handling. That two-step check, missing then empty, is a small pattern that appears throughout front-end code, wherever data arrives asynchronously and may be absent, empty, or populated.
console.log([].length === 0); // true
console.log(Object.keys({}).length === 0); // true
console.log(Boolean([])); // true — a trap! Empty arrays are truthy.
console.log(Boolean({})); // true — empty objects are truthy too.
const results = [];
if (results.length === 0) {
console.log("No results found");
}
Check: Why does if ([]) not tell you whether an array is empty?
Section 48 — Changing data versus making new data
Some array methods change the original (push, pop, sort, shift, unshift, splice); others return a new array (map, filter, slice, spread, concat). Knowing which kind you are using avoids surprises, and returning new data makes code easier to reason about, because a function returning new data has no hidden effect on its input. That mutating versus non-mutating distinction is one of this chapter's most important organising ideas, worth memorising, or at least knowing how to check: documentation says whether a method mutates, and a quick console experiment — log the original before and after — settles doubt. As a hint, not a rule, action verbs like push, pop, and sort tend to mutate, while producing verbs like map and filter tend to return new data.
Non-mutating style is popular in modern front-end code for predictability, especially with shared state. If a list is displayed in one place and edited in another, mutating it changes the display immediately, which can be convenient, but the display then has no record of what changed, when, or why, and undoing requires reconstructing the old list. Returning a new list keeps the old intact, so the program can compare old and new, keep a history, or revert by restoring the old list. That is why state libraries favour non-mutating updates: they detect change simply by noticing a different reference, because a new list means a change happened. Even in plain code, keeping old versions makes debugging easier, because you can log both and see exactly what changed.
Mutating style is not wrong, and is often simpler and faster for local data no other code holds. A loop collecting results by pushing into a local array, or a list built once then displayed, needs no ceremony of building new arrays at each step. Choose deliberately: mutate when data is local, short-lived, and owned by one piece of code, and return new data when data is shared, long-lived, or part of state other parts depend on. Ask: "who else holds this list, and would they be surprised if it changed?" If "nobody", or "they expect it", mutating is fine. If "several parts, and they would need to know", returning new data is safer, because the change becomes explicit where the new list replaces the old.
Practise the distinction by doing the same job both ways: double a list of numbers by mutating with a loop assigning back into the array, then double them again by mapping to a new array, logging the original after each. The first changes the original; the second leaves it intact. Seeing both side by side makes the distinction concrete, and gives you a test for any method you are unsure about: does the original change, or stay the same? Run that test once per new method, and you build a reliable catalogue of mutating versus non-mutating tools.
const numbers = [1, 2, 3];
const doubled = numbers.map(function (n) { return n * 2; });
console.log(numbers); // [1, 2, 3] — map did not mutate
console.log(doubled); // [2, 4, 6]
numbers.push(4); // push mutates
console.log(numbers); // [1, 2, 3, 4]
const sortedCopy = [...numbers].sort(function (a, b) { return b - a; });
console.log(numbers); // still [1, 2, 3, 4] — sorted a copy
Check: Name one method that changes the original array and one that returns a new one.
Section 49 — Common array and object mistakes
The usual traps are: off-by-one errors from forgetting zero-based indexes, reading a property of undefined, expecting === to compare object contents, and forgetting that sort() changes the original. Each has a distinctive symptom and first check, turning debugging from guessing into a checklist. Off-by-one shows as the wrong item or undefined at a list's end; check the index: counting from zero, and does the loop stop before the length? Property-of-undefined crashes with that message; check the level before the failing property. An === failure should pass but does not; check whether both sides are objects, where identity, not contents, is compared. A sort surprise loses the original order; check whether the code sorted the original instead of a copy.
Other traps belong on the checklist too. Forgetting to return from a map() or filter() callback produces arrays of undefined or wrongly filtered results; check the callback's return statement. Using forEach() when a result is needed produces undefined where a list was expected; check the method choice: should this be map() or filter()? Testing an empty array or object with truthiness produces a branch that never runs for the empty case; check the emptiness test, which should use length or keys. Mutating a shared object or array produces changes in unexpected places; check whether two variables point at the same container, revealed by logging both before and after the change. Each trap is simple alone; symptoms appear far from causes, so a checklist pointing back to the cause saves time.
A habit covering most traps is to log the data, not just the error. When a list displays wrongly, log the array, its length, and the item you expected, before changing code. When a property reads as undefined, log the whole object and check its keys, rather than assuming the property should be there. When a comparison fails, log both sides and their types. That data-first approach is faster than reasoning in your head, because data often reveals a type mismatch, missing field, or off-by-one shift the code alone did not make obvious. The console is a diagnostic tool, not just a place to print greetings, and inspecting data at the point of failure is this book's most useful debugging skill.
Finally, every developer makes these mistakes, including experienced ones. The difference is not that experts never write off-by-one errors or forget a return; they recognise symptoms quickly, because they have seen them before, and have a checklist to run. You are building that checklist now, by meeting each trap in a controlled chapter rather than a large, confusing project. When you hit one in your own code, do not treat it as a sign you are not cut out for programming. Treat it as a familiar trap, run its first check, fix it, and add the experience to your checklist. That attitude — mistakes as familiar, fixable patterns — turns a beginner into a developer who can debug confidently.
const fruits = ["apple", "banana"];
console.log(fruits[2]); // undefined — off by one, or past the end
const user = {};
// console.log(user.address.city); // TypeError — address is missing
console.log(user.address?.city); // undefined — safe version
console.log({ a: 1 } === { a: 1 }); // false — identity, not contents
Check: You get an error reading a property of undefined. What is the most likely cause?
Section 50 — Chapter 18 in one paragraph
Arrays hold ordered lists, objects hold named properties, and together — especially as arrays of objects — they model almost all the data a front-end app handles. That sentence, expanded, is the chapter in miniature: lists for sequences, records for named facts, and lists of records for the collections a page displays, from products to posts to users. Learn to create, read, change, copy, loop over, filter, map, and sort them, and you can hold, shape, and display any data a page needs, because every later skill — fetching data from a server, rendering it into the DOM, handling a form's values, or managing an app's state — is built on those operations, applied to arrays and objects in combination. The details matter, and they are worth revisiting: zero-based indexes, length minus one, mutating versus non-mutating methods, shallow copies, reference identity, and safe handling of missing data are not trivia, they are the rules that determine whether your data code works reliably or fails in confusing ways.
If you take one habit from this chapter, make it this: when data looks wrong, inspect the data itself, check the index or property name, and check whether you mutated something shared or compared objects by identity. Those three checks cover most data bugs, and they will serve you in every chapter that follows. If you take one design habit, make it this: model a thing as an object when its parts have names, as an array when its parts are a sequence of similar items, and as an array of objects when you have a list of such things, keeping each object's shape consistent and its types predictable. With those habits, plus the methods in this chapter, you are ready for the next step, which is turning data into a visible page: the DOM chapter takes the arrays and objects you can now shape, and shows you how to render them, update them, and keep the display in step with the data, which is where data skills become visible skills.
Before moving on, test yourself honestly on the chapter's core operations, without looking back: create an array, push and pop, loop over it two ways, map it, filter it, and find an item; create an object, read and change a property, copy it with spread, and explain why a shallow copy shares nested objects; and describe, in your own words, when you would use an array and when you would use an object. If any of those feels shaky, re-read its section and try its console examples, because the next chapter assumes them and will move faster. Data is the foundation of front-end work, and a solid foundation here makes everything after it easier, from DOM rendering to event handling to fetching real data from real servers, where the data will arrive as arrays of objects and your job will be to shape and display it well.
Check: In your own words, when would you use an array, and when would you use an object?
Practical task
Build a small "book shelf" page: in JavaScript, create an array of at least five book objects, each with a title, author, and pages. Use filter() to keep only books with more than 200 pages, use map() to turn those into strings like "Title by Author", and display the resulting list on the page as an unordered list. Also add a button that sorts the books by pages (shortest first) and re-displays the list, so you practise both transforming and sorting an array of objects. Extend the task if you finish early: add a second button that shows only books by a chosen author, using filter() again, and make sure the original array is never mutated by your sorting — sort a copy with spread, and log the original afterwards to prove it is unchanged.
Chapter questions
- Write code that creates an array of three colours, adds a fourth with push(), and logs the length.
- Explain the difference between map() and forEach(), and say when you would use each.
- Given
const user = { name: "Ada", address: { city: "Lagos" } };, write the code to read the city, and explain whyuser.address?.cityis safer thanuser.address.cityif address might be missing. - Why does
const a = { x: 1 }; const b = a; b.x = 2;also changea.x? How would you make a copy that does not share the top-level properties? - Write a filter() call that, from an array of product objects with
priceandinStockproperties, returns only products that are in stock and cost less than 2000.