I2 · Part 2 · Intermediate
Objects deeply
The rest of the object/array toolbox — past what the beginner chapter covered.
15On this page
- Getters and setters
- Object statics — the whole-object toolkit
- Copying — shallow, deep, and what actually does which
- Map and Set — objects and arrays with better rules
- WeakMap and WeakSet
- Array methods the beginner chapter skipped
- The immutable array twins — toSorted, toReversed, toSpliced, with
- findLast and findLastIndex
- Object.groupBy — grouping without a reduce
- Set gets algebra: union, intersection, difference
- Locking an object down: seal, freeze, preventExtensions
- Sort stability
- JSON — replacer and reviver
- Nested destructuring and defaults, past the basics
- Optional chaining meets deep data
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 mutates its first argument
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.
Dry run: which lookups actually hit
| Call | Key compared against | Same reference as objKey? | Result |
|---|---|---|---|
cache.set(objKey, "cached result") | — | — | entry stored under objKey itself |
cache.set("plain-string-key", "also fine") | — | — | a second, unrelated entry — size is now 2 |
cache.get(objKey) | objKey | yes — literally the same object | "cached result" |
cache.get({ id: 1 }) | a brand-new object, same shape | no — different object in memory | undefined |
Nothing about the shape of the key matters to a
Map — only whether it's the exact same reference (or an
identical primitive). Rebuilding an object with the same fields never
gets you back the entry it was stored under.
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
Rule Reach for a
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 the beginner chapter skipped
| 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
The immutable array twins — toSorted, toReversed, toSpliced, with
Four methods added specifically because their mutating originals are
a common accidental-mutation bug: .sort(),
.reverse(), .splice(), and index assignment
each now have a non-mutating sibling that returns a brand-new array
and leaves the original untouched.
const original = [3, 1, 2];
original.toSorted(); // [1, 2, 3] — original is untouched
original.toReversed(); // [2, 1, 3]
original.toSpliced(1, 1); // [3, 2] — removes index 1, returns the result, doesn't touch original
original.with(0, 99); // [99, 1, 2] — like original[0] = 99, but as a copy
console.log(original); // [3, 1, 2] — every one of the four left it exactly as it was
Before these existed, the standard workaround was
[...original].sort() — copy first, then mutate the copy.
The to*/with methods do the same thing in
one call, and read as intent rather than a defensive habit: "give me a
sorted copy," not "copy this so it's safe to sort."
findLast and findLastIndex
const orders = [
{ id: 1, status: "shipped" },
{ id: 2, status: "pending" },
{ id: 3, status: "shipped" },
];
orders.find((o) => o.status === "shipped"); // order 1 — first match
orders.findLast((o) => o.status === "shipped"); // order 3 — last match, still one pass, just walked from the end
Before findLast, "the most recent matching item" meant
reversing the array first (an allocation) or writing the loop
backward by hand. It's the same relationship .find and
.findLast now have that .indexOf and
.lastIndexOf already had for plain values.
Object.groupBy — grouping without a reduce
const people = [
{ name: "Ana", team: "core" },
{ name: "Ravi", team: "core" },
{ name: "Mei", team: "infra" },
];
const byTeam = Object.groupBy(people, (p) => p.team);
console.log(byTeam);
// { core: [{name:"Ana",...}, {name:"Ravi",...}], infra: [{name:"Mei",...}] }
This used to be a reduce everyone wrote slightly
differently — start an accumulator object, check whether the key
exists yet, push or initialize. Object.groupBy is exactly
that pattern, standardized and readable at the call site.
Map.groupBy is the same idea with a real
Map as the result, useful the moment a group key isn't a
plain string.
Set gets algebra: union, intersection, difference
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
a.union(b); // Set(4) {1, 2, 3, 4}
a.intersection(b); // Set(2) {2, 3}
a.difference(b); // Set(1) {1} — in a, not in b
a.symmetricDifference(b); // Set(2) {1, 4} — in exactly one of the two
a.isSubsetOf(new Set([1, 2, 3, 4])); // true
Before these, a set operation meant dropping to
[...a].filter((x) => b.has(x)) and back into a
new Set(...) — correct, but re-derived by hand every
time. These five are the textbook set-theory operations, built in,
and every one returns a brand-new Set without touching
either input.
Locking an object down: seal, freeze, preventExtensions
| Call | Add new keys? | Delete keys? | Change existing values? |
|---|---|---|---|
Object.preventExtensions(o) | no | yes | yes |
Object.seal(o) | no | no | yes |
Object.freeze(o) | no | no | no |
const config = Object.freeze({ retries: 3 });
config.retries = 99; // silently does nothing in sloppy mode, throws a TypeError in strict mode/modules
console.log(config.retries); // 3 — unchanged either way
⚠ freeze is shallow, exactly like spread
Object.freeze({ nested: { count: 1 } }) only locks the
top level — frozen.nested.count = 99 works fine, because
nested itself was never frozen. A genuinely deep freeze
means walking every nested object and freezing each one individually.
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: <a real Date object> } — 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.
Interview answer, one sentence
"Copies are shallow unless you make them deep — spread and Object.assign copy one level, structuredClone copies deeply but not functions — Map and Set beat plain objects for arbitrary keys and uniqueness, and toSorted and its family give non-mutating versions of the array methods."
Before you move on
- Explain why
{ ...base }andstructuredClone(base)behave differently on an object with a nested field. - Say why
cache.get({ id: 1 })misses an identical-looking stored object, in terms of howMapcompares keys. - Pick
Mapover a plain object for a specific reason (key type, iteration order, or no inherited baggage) instead of by habit. - Explain why
Object.freezedoesn't stop a nested object's fields from changing. - Use a JSON replacer or reviver to solve a concrete problem — dropping a field on the way out, reviving a
Dateon the way in.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series