Objects, Arrays, and Destructuring in JavaScript

Published Updated

If a variable is a luggage tag, an object is a labelled shelf and an array is a numbered row of pigeonholes. Assigning one to a second variable ties a second tag to the same shelf rather than building a new one. That sharing is the difference between code that works and code that changes something across the room.

This guide covers building and reading objects, the array methods that do most of the work in real code, destructuring, spread and rest, when Map and Set beat a plain object, and the copy-against-mutate decision behind a whole family of bugs.

Object Literals

An object literal is a set of named slots written in braces. Values can be any type, including other objects and functions:

const product = {
  name: "Desk lamp",
  "list price": 49.5,
  stock: { warehouse: 3 },
};

console.log(product.name);
console.log(product.stock.warehouse);
// node output:
// Desk lamp
// 3

Keys are strings or symbols, and anything else you write as a key is converted, normally to a string. Quotes are only needed when the key is not a valid identifier, as with "list price". API responses frequently use keys with hyphens or spaces, and those keys need bracket access rather than a dot.

Two shorthands appear constantly in modern code. When a variable name matches the key you want, { name } means { name: name }. Methods can drop the function keyword, so { save() { } } is a method named save.

Property Access

Dot access uses a fixed name written into the code. Bracket access takes any expression, which is what you need when the key is in a variable:

const product = { name: "Desk lamp", "list price": 49.5 };
const field = "name";

console.log(product[field]);
console.log(product["list price"]);
console.log(product.colour);
// node output:
// Desk lamp
// 49.5
// undefined

The last line is the behaviour that defines JavaScript objects. When a lookup finds nothing on the object or anything it inherits from, it returns undefined rather than raising an error, so a typo in a property name fails silently at the point it is made and loudly somewhere else entirely.

That is what makes ?. from the values and equality guide so useful on nested data. Reading product.stock.warehouse when stock is missing throws immediately, while product.stock?.warehouse evaluates to undefined and lets you supply a fallback.

Objects are not arrays, so they have no map or filter of their own. Three built-in helpers convert an object into something you can loop over:

const product = { name: "Desk lamp", price: 49.5, stock: 3 };

console.log(Object.keys(product));
console.log(Object.values(product));

for (const [key, value] of Object.entries(product)) {
  console.log(key, "=", value);
}
// node output:
// [ 'name', 'price', 'stock' ]
// [ 'Desk lamp', 49.5, 3 ]
// name = Desk lamp
// price = 49.5
// stock = 3

Object.entries is the one you reach for most, because it hands you a key and a value together and everything you know about array methods then applies. Object.fromEntries goes the other way, turning a list of pairs back into an object after you have filtered or transformed it.

The Array Methods Worth Knowing

Four methods cover most day-to-day array work. Each takes a callback and each answers a different question:

const orders = [
  { id: "A1", total: 40, status: "paid" },
  { id: "A2", total: 12, status: "refunded" },
  { id: "A3", total: 95, status: "paid" },
];

console.log(orders.filter((order) => order.status === "paid").map((order) => order.id));
console.log(orders.reduce((sum, order) => sum + order.total, 0));
console.log(orders.find((order) => order.total > 500));
// node output:
// [ 'A1', 'A3' ]
// 147
// undefined

filter keeps the elements whose callback returns something truthy and always returns an array. map transforms every element and returns an array of the same length. reduce folds the whole list into one value, starting from the second argument, which is 0 here.

find returns the first matching element, or undefined when nothing matches, as the last line shows. That undefined is the origin of a great many "cannot read properties of undefined" errors, because the next line usually reads a property of the result.

Two more are worth adding early. some and every answer yes-or-no questions about a list without building anything, and forEach runs a callback purely for its side effects and returns undefined. Reaching for forEach when you meant map is why some code ends up pushing into an array by hand.

For plain membership there is no callback at all. list.includes(value) returns a boolean, and list.indexOf(value) returns the position or -1 when the value is absent. Older code tends to use indexOf with a !== -1 check, which includes replaced.

Because filter and map both return arrays, they chain. The first line in the example above reads as one sentence, left to right: keep the paid orders, then take their ids. Chaining stays readable for two or three steps, and a chain longer than that usually wants intermediate variables with names.

Destructuring

Destructuring pulls values out of an object or array into named variables in one line. It is pure convenience, and it is used so widely that unfamiliarity with it makes modern code unreadable:

const order = { id: "A1", total: 40, status: "paid" };

const { id, total, status: state } = order;
console.log(id, total, state);

const [first, ...rest] = ["A1", "A2", "A3"];
console.log(first, rest);
// node output:
// A1 40 paid
// A1 [ 'A2', 'A3' ]

Object destructuring matches by key name, so order does not matter. The colon in status: state renames on the way out, which resolves collisions when two objects both have an id. Array destructuring matches by position instead.

The most useful place for it is a function signature, where it names exactly which fields the function uses and supplies defaults in the same breath:

function describe({ id, total = 0, currency = "AUD" }) {
  return `${id}: ${total} ${currency}`;
}

console.log(describe({ id: "A9" }));
// node output:
// A9: 0 AUD

The caller passed one field and the other two came from defaults. This is exactly how React components receive props, so the pattern is worth being comfortable with before any framework code arrives.

Destructuring also nests, which is how people dig into an API response in one line. It is powerful and it gets unreadable quickly, so it is worth knowing how to read even if you write it sparingly:

const response = { data: { items: [{ id: "A1" }] }, meta: {} };

const { data: { items: [firstItem] }, meta: { page = 1 } } = response;
console.log(firstItem, page);
// node output:
// { id: 'A1' } 1

Read it from the outside in: reach into data, then into items, then take position zero and call it firstItem. The default on page filled in because meta had no such key. A default cannot save you from a missing intermediate level, so destructuring data when data itself is absent still throws.

Spread and Rest

The three dots do two opposite jobs, and which one you get depends on where they sit grammatically. Spread appears in argument lists and inside array or object literals, unpacking a value into them. Rest appears in parameter lists and in binding patterns, collecting whatever is left:

const base = { theme: "dark", pageSize: 20 };
const merged = { ...base, pageSize: 50 };

console.log(merged);
// node output:
// { theme: 'dark', pageSize: 50 }

Later keys win, which makes this the standard way to apply an override without touching the original. The same syntax works on arrays, where [...listA, ...listB] concatenates and [...list] makes a copy.

Rest is the form you already saw in the destructuring example and in the rest parameters of the functions guide. Same three dots, opposite direction: in a pattern that receives values they gather, and in a literal or a call that produces values they scatter.

Copy Against Mutate

Assigning an object hands over a value that identifies the shelf, not the shelf's contents. Two variables can therefore identify the same shelf, and writing through one is visible through the other, while pointing one variable somewhere else leaves the other alone. Spread makes a copy, but only of the top level:

const original = { theme: "dark", filters: { tag: "js" } };
const shallow = { ...original };

shallow.filters.tag = "css";
console.log(original.filters.tag);

const deep = structuredClone(original);
deep.filters.tag = "html";
console.log(original.filters.tag, deep.filters.tag);
// node output:
// css
// css html

Editing the nested object through the copy changed the original, because both copies pointed at the same nested object. structuredClone, built into modern browsers and Node, copies the whole structure, and the second edit stayed contained.

You will also meet Object.assign(target, source), which copies the same own enumerable properties one level deep. It writes into the target you pass, so Object.assign(original, patch) modifies original rather than returning something new.

The two are not interchangeable beyond that. Object.assign assigns, so it triggers any setter already on the target, while spread always creates plain data properties. Neither copies the source's prototype or its non-enumerable properties.

structuredClone is not a universal answer either. It copies plain data, arrays, dates, maps, and sets, and handles cycles, but it throws on functions. A class instance comes back as a plain object, losing its prototype, its getters, and settings such as read-only.

For plain data it is still the shortest correct option. The older JSON.parse(JSON.stringify(value)) trick silently loses dates and undefined values.

Array methods split the same way, and the split is not obvious from the names. sort, reverse, splice, push, and pop change the array in place. map, filter, slice, and concat return a new one and leave the original alone.

const tags = ["b", "a"];

console.log(tags.toSorted());
console.log(tags);
console.log(tags.sort());
console.log(tags);
// node output:
// [ 'a', 'b' ]
// [ 'b', 'a' ]
// [ 'a', 'b' ]
// [ 'a', 'b' ]

The newer toSorted, toReversed, and toSpliced methods do the non-mutating version of each. They are available in current browsers and in Node 20 and later, so check your minimum supported version before relying on them.

When to Reach for Map and Set

A plain object works as a lookup table until its keys stop being simple strings. A Map accepts any value as a key without converting it to a string, and reports its own size:

const counts = new Map();
counts.set("js", 1).set("css", 2);
console.log(counts.get("js"), counts.size, counts.has("php"));

const key = { id: 1 };
counts.set(key, "object key");
console.log(counts.get(key), counts.get({ id: 1 }));
// node output:
// 1 2 false
// object key undefined

The last line shows why object keys are compared by identity, not contents. The second lookup used a different object that happens to look identical, and found nothing. A plain object would have converted both keys to the string "[object Object]" and collided instead.

Key matching is not quite ===, in two useful ways. A Map can find a NaN key, which === could never match, and it stores -0 as 0.

Set is the same idea for values with no duplicates. new Set(list) followed by [...unique] is the shortest way to deduplicate an array of primitives, and set.has(value) is a membership test that does not scan the list.

Plain objects still win in two places. They are what JSON.parse hands back for a JSON object, and what JSON.stringify serialises directly, whereas a Map or Set needs conversion first because its entries are not ordinary properties. They also read better for fixed, known-in-advance shapes such as a configuration block.

Pitfalls and Debugging

TypeError: Cannot read properties of undefined after a find. The classic sequence is orders.find(...) returning undefined, then the next line reading .total from it. Check the result before using it, or use ?. plus a fallback when a miss is legitimate.

Object keys silently becoming strings. Setting obj[1] and reading obj["1"] gives the same slot, and Object.keys reports ['1']. When numeric or object keys must stay distinct, use a Map.

A sorted list appearing somewhere you did not sort. sort mutates, so sorting an array you were handed changes it for whoever else holds it. Copy first with [...list].sort() or use toSorted.

Numbers sorted in the wrong order. [10, 9, 100].sort() gives [10, 100, 9], because the default comparison converts elements to strings. Pass a comparator: list.sort((a, b) => a - b).

Nested state changing in two places at once. Almost always a shallow copy where a deep one was needed. In React and other frameworks that detect updates by comparing references, this shows up as a UI that does not update, because the top-level reference never changed even though the contents did.

Comparing two objects with ===. Identical contents are not equal, since the comparison is by identity. Compare the fields that matter. Comparing serialised forms is a shortcut that only holds for fully JSON-safe data with matching key order, since functions, dates, Map, Set, and undefined are dropped or transformed on the way through.

Frequently Asked Questions

When should you use a Map instead of an object?

Use a Map when keys are added and removed at runtime, when you need the count, or when keys are not strings. An object converts most key values to strings, so the number 1 and the string "1" collide. A Map accepts any value as a key, including objects.

Why did editing my copy change the original?

Spread and Object.assign copy one level deep. The top-level properties are new, but a nested object or array is the same value in both copies, so editing it shows up in each. Use structuredClone for a deep copy of plain data, or rebuild the nested level explicitly.

What's the difference between forEach and map?

Both run a function once per element. forEach returns undefined and exists for side effects, while map collects each return value into a new array of the same length. If you are building a new list, use map; if you are only doing something, use forEach.

Does sort change the original array?

Yes. sort, reverse, splice, push, and pop all modify the array in place, which is why a sorted list sometimes changes something you did not expect. Copy first with a spread, or use the newer toSorted, toReversed, and toSpliced methods, which return a new array.

Sources

  1. [1]
    Working with Objects
    (developer.mozilla.org)
  2. [2]
    Indexed Collections
    (developer.mozilla.org)
  3. [3]
    Keyed Collections
    (developer.mozilla.org)