Objects deeply
The rest of the object/array toolbox — past what B5 already covered.
The first pass at objects and arrays covered literals, the core mutating/non-mutating array methods, and basic destructuring/spread. This chapter is everything past that: computed behavior on objects, the two collection types that aren't arrays, and the JSON details that only bite in real apps.
Getters and setters
A property that runs code on read or write, while still looking like
a plain field to anything using it — no () at the call
site.
const person = {
first: "Ana",
last: "Rao",
get fullName() {
return this.first + " " + this.last;
},
set fullName(value) {
[this.first, this.last] = value.split(" ");
},
};
console.log(person.fullName); // what happens?
person.fullName = "Ravi Shah"; // looks like a plain assignment
console.log(person.first, person.last); // what happens?
"Ana Rao", then "Ravi" "Shah" — the setter
ran and split the incoming string back into two real fields. This is
the standard way to keep a derived value (fullName) in
sync with the data it's derived from, without callers ever calling a
method to get it.
Object statics — the whole-object toolkit
| Call | Returns |
|---|---|
Object.keys(obj) | array of own, enumerable key names |
Object.values(obj) | array of the matching values |
Object.entries(obj) | array of [key, value] pairs — feeds straight into a for...of or new Map() |
Object.fromEntries(pairs) | the reverse — pairs back into an object |
Object.assign(target, ...sources) | copies own enumerable props from each source onto target, left to right — mutates target |
Object.hasOwn(obj, key) | true only for the object's own property, never an inherited one |
const o = { a: 1, b: 2 };
Object.entries(o); // [["a", 1], ["b", 2]]
Object.fromEntries([["x", 1], ["y", 2]]); // { x: 1, y: 2 }
Object.hasOwn(o, "a"); // true
Object.hasOwn(o, "toString"); // false — toString is inherited, not o's own
Object.assign(base, patch) changes base in
place and returns it. To merge without touching either input, pass an
empty object as the target — or reach for spread instead:
{ ...base, ...patch } does the same merge, immutably.
Copying — shallow, deep, and what actually does which
Already established:
spread and Object.assign both copy one level deep, so a
nested object stays shared. For a real deep copy of plain
data, structuredClone() is the built-in answer — no
library, works on objects, arrays, Date, Map,
Set, and it correctly throws rather than silently
mangling a function or a DOM node it can't clone.
const original = { nested: { count: 1 }, tags: new Set(["a"]) };
const deep = structuredClone(original);
deep.nested.count = 99;
console.log(original.nested.count); // 1 — untouched, unlike a spread copy
console.log(deep.tags instanceof Set); // true — the Set survived the clone
Map and Set — objects and arrays with better rules
A Map is a key/value store like an object, but with two
things a plain object can't do: any value can be a key
(not just strings/symbols), and it remembers insertion order
reliably, including for keys that look numeric.
const objKey = { id: 1 };
const cache = new Map();
cache.set(objKey, "cached result");
cache.set("plain-string-key", "also fine");
console.log(cache.get(objKey)); // what happens?
console.log(cache.get({ id: 1 })); // a DIFFERENT object, same shape — what happens?
console.log(cache.size);
"cached result", then undefined. Map keys
are compared by identity, same as everything else about
object references — a freshly-built { id: 1 } is not the
objKey it was stored under, no matter how identical it
looks. This is exactly why an object can be used to key a private,
un-guessable cache entry.
Object |
Map |
|
|---|---|---|
| Key types | strings and symbols only | anything — objects, functions, NaN |
| Size | Object.keys(o).length | map.size, directly |
| Iteration order | mostly insertion, but integer-like keys sort first — a real gotcha | always insertion order, no exceptions |
| Extra baggage | inherits from Object.prototype (toString, etc.) | starts empty — nothing to accidentally collide with |
Set is the same idea for values with no key at all —
a list that silently refuses duplicates, compared the same way
Map keys are:
const unique = new Set([1, 2, 2, 3, 3, 3]);
[...unique]; // [1, 2, 3]
unique.has(2); // true
[...new Set(array)]; // the standard one-liner for "de-duplicate this array"
WeakMap and WeakSet
Same idea as Map/Set, with one restriction
and one superpower: keys (or values, for a WeakSet) must
be objects, and they're held weakly — if nothing else in the
program references that object anymore, the garbage collector is
free to remove it, entry and all.
const wm = new WeakMap();
let el = { id: "temp" };
wm.set(el, { extra: "metadata tied to el's lifetime" });
el = null; // no other reference to the object exists anymore —
// the WeakMap's entry can now be garbage collected too
WeakMap when
you're attaching extra data to objects you don't own the lifetime of
— DOM nodes, other modules' objects — so that data doesn't
accidentally keep them alive forever. A regular Map
would hold a strong reference and leak memory as long as the map
itself exists.
Array methods B5 didn't cover
| Call | Does |
|---|---|
Array.from(iterable, mapFn?) | builds a real array from anything iterable OR array-like — a string, a Set, a { length: n } object — with an optional map step built in |
arr.flat(depth) | flattens nested arrays depth levels (default 1) |
arr.flatMap(fn) | .map(fn).flat(1), done in one pass — for when a mapper sometimes returns 0 or several items per input |
arr.at(-1) | same as arr[arr.length - 1], but works with negative indices directly |
Array.from({ length: 3 }, (_, i) => i * 2); // [0, 2, 4] — no real array needed to start
Array.from("abc"); // ["a", "b", "c"]
[1, [2, [3, [4]]]].flat(2); // [1, 2, 3, [4]] — only 2 levels deep
[1, 2, 3].flatMap(x => [x, x * 10]); // [1, 10, 2, 20, 3, 30]
[1, 2, 3].at(-1); // 3
Sort stability
Modern Array.prototype.sort is guaranteed
stable: elements that compare equal keep their original
relative order. That's not a minor implementation detail — it's what
makes multi-key sorting possible with two simple, separate sorts.
const items = [
{ key: "a", group: 1 },
{ key: "b", group: 1 },
{ key: "c", group: 0 },
];
const sorted = items.sort((x, y) => x.group - y.group);
console.log(sorted.map((i) => i.key)); // what happens?
["c", "a", "b"] — "a" and "b"
both have group: 1, tied under the comparator, and
stability guarantees they stay in their original relative order (a
before b) rather than the sort being free to swap them arbitrarily.
JSON — replacer and reviver
Both stringify and parse take an optional
second function that runs on every key/value pair — a hook to filter
or transform as the conversion happens, instead of after.
JSON.stringify(
{ name: "Ana", email: "ana@x.com", passwordHash: "…" },
(key, value) => (key === "passwordHash" ? undefined : value)
); // {"name":"Ana","email":"ana@x.com"} — dropped before it ever became text
JSON.parse(
'{"createdAt":"2024-01-01T00:00:00.000Z"}',
(key, value) => (key === "createdAt" ? new Date(value) : value)
); // { createdAt: } — JSON has no date type, so this is how you get one back
The replacer can also be an array instead of a function — a plain allow-list of key names to keep, everything else dropped. Simpler when you just need a fixed subset of fields, no per-key logic.
Nested destructuring and defaults, past the basics
function render({
user: { name, address: { city = "Unknown" } = {} } = {},
theme = "light",
} = {}) {
return name + " · " + city + " · " + theme;
}
render({ user: { name: "Ana" } }); // "Ana · Unknown · light"
render(); // no crash — every level has a fallback
Each = {} is a default for that specific level
— without it, destructuring a level that's missing (like
address not existing on a bare { name: "Ana" })
throws instead of quietly falling through, because you can't
destructure a property off of undefined.
Optional chaining meets deep data
const config = { server: { retries: 0 } };
config.server?.timeout ?? 5000; // 5000 — timeout doesn't exist, ?? catches it
config.server?.retries ?? 5000; // 0 — retries DOES exist, so its real value wins
config.client?.host ?? "localhost"; // "localhost" — client itself is missing, chain stops safely
That middle line is the one worth sitting with:
?? only falls back on null/undefined,
so a genuinely present 0 survives untouched — exactly
the combination (?. to reach safely,
?? to default correctly) that a plain
config.server && config.server.retries || 5000
gets wrong, because || would treat that real
0 as missing too.
Opens in the editor — write it, run it, and check it against real tests.