Technology  /  JavaScript

🟨 JavaScript 15 guides · updated 2026

Closures, the event loop, promises, and modern ES syntax — the language of the web explained the way it actually behaves at runtime.

JavaScript Objects, Destructuring, and Spread: Immutable Updates Done Right

Objects are JavaScript’s universal container — every config, API payload, state blob, and options bag is one. Modern JS layered a syntax toolkit on top of them (destructuring, spread, shorthand, optional chaining) that changed how the language looks: code written since ~2018 is dense with { ...state, name } and const { data } = res in ways older tutorials never mention.

This guide covers that toolkit properly — including the shallow-copy trap hiding inside spread, the immutable-update patterns frameworks assume, and the honest answer on when a Map beats a plain object.


Modern Object Syntax in Ninety Seconds

const name = "Ada", role = "admin";
const user = {
name, // shorthand: name: name
role,
[`${role}Since`]: 2024, // computed key: adminSince
greet() { // method shorthand
return `Hi, ${this.name}`;
},
};

Property shorthand ({ name }), computed keys ([expr]:), and method shorthand are pure convenience — but they’re everywhere, so reading them must be automatic. Two access notes: dot for known keys, brackets for dynamic ones (user[fieldName]); and key order — objects preserve insertion order for string keys (with integer-like keys sorted first — a quirk to know, not to rely on).

Property existence versus value is a distinction worth keeping crisp: "role" in user and Object.hasOwn(user, "role") test presence; user.role !== undefined conflates “absent” with “present but undefined.” The iteration workhorses:

Object.keys(user) // ["name", "role", "adminSince", "greet"]
Object.values(user)
Object.entries(user) // [["name","Ada"], ...] — pairs → destructure in loops:
for (const [key, value] of Object.entries(user)) { ... }
Object.fromEntries(pairs) // the inverse — build objects from pairs

entries/fromEntries bracket a useful pattern: transform an object by round-tripping through pairs and array methodsObject.fromEntries(Object.entries(obj).filter(...)).


Destructuring: Unpacking as Syntax

Destructuring pulls values out of objects/arrays in the shape you declare:

const res = { status: 200, data: { id: 7, name: "Ada" }, meta: null };
const { status, data } = res; // two consts in one line
const { data: { name } } = res; // nested reach-in
const { missing = "fallback" } = res; // default (fires on undefined only)
const { status: code } = res; // rename: code = 200
const { status: s2, ...rest } = res; // rest: everything else, new object
// arrays destructure by position
const [first, second] = [10, 20, 30];
const [head, ...tail] = list;
const [, , third] = list; // skip with holes
const [a, b] = [b, a]; // the classic swap
// and it composes with iteration and returns
for (const [k, v] of Object.entries(config)) { ... }
const [x, y] = getCoordinates(); // multi-value returns, tuple-style

The highest-value application is function parameters — the options-object pattern from the functions guide, which destructuring makes ergonomic:

function paginate({ page = 1, size = 20, sort = "id" } = {}) { ... }
paginate({ size: 50 }); // named, order-free, defaulted
paginate(); // works too — note the outer = {} guard

That trailing = {} matters: without it, calling paginate() destructures undefined and throws. With it, everything defaults. This exact signature shape is modern JS API design in one line.

Two cautions: deep destructuring of possibly-missing branches throws (const { data: { name } } = res explodes if data is null — guard with defaults or optional chaining first), and heavily nested destructures can obscure more than they reveal. Two levels is a sensible ceiling.


Spread and the Shallow-Copy Trap

Spread (...) expands an object’s (or array’s) entries into a new literal:

const defaults = { theme: "light", lang: "en", retries: 3 };
const userPrefs = { lang: "hi" };
const config = { ...defaults, ...userPrefs }; // merge — LATER WINS
// { theme: "light", lang: "hi", retries: 3 }
const updated = { ...config, retries: 5 }; // "change" one field, new object

Later-wins ordering makes spread the standard tool for defaults-then-overrides merging and for immutable updates. But the fine print is the section title: spread copies one level deep. Nested objects are copied by reference — shared, not duplicated:

const state = { user: { name: "Ada" }, tags: ["a"] };
const copy = { ...state };
copy.user.name = "Grace"; // ALSO changes state.user.name — same inner object!
copy.tags.push("b"); // ALSO visible via state.tags
copy.user = { name: "Alan" }; // this is fine — replacing, not mutating

The rule that resolves it: to update a nested field immutably, spread every level on the path:

const next = {
...state,
user: { ...state.user, name: "Grace" }, // new outer, new user, shared rest
};

Verbose? Yes — deliberately: the verbosity is the path being copied. This nested-spread idiom, plus the array equivalents (map-with-conditional-spread from the array guide), is the backbone of state updates in React/Redux-style code. When the nesting gets truly deep, that’s a signal to flatten your state shape — or reach for a genuine deep copy:

const clone = structuredClone(state); // real deep copy — modern, handles Dates,
clone.user.name = "Grace"; // Maps, Sets, cycles; not functions/DOM nodes

structuredClone retired the JSON.parse(JSON.stringify(x)) hack (which silently dropped undefined, Dates-as-objects, Maps, and functions). Deep-copy sparingly, though — it’s O(everything); the spread-the-path idiom shares all unchanged branches, which is both faster and what makes reference-equality change detection work.

Why does the ecosystem insist on immutability at all? The same reasons as in Java — local reasoning, safe sharing — plus one JS-specific superpower: frameworks detect changes by cheap reference comparison. New object = changed; same object = skip. Mutation breaks that contract invisibly.


Optional Chaining and Friends: Navigating Uncertain Shapes

API data is uncertain by nature; ES2020’s operators made navigating it civilized:

const city = order?.customer?.address?.city ?? "unknown";
const first = order?.items?.[0]; // optional index
const label = order?.format?.(); // optional call

?. short-circuits to undefined at the first null/undefined; ?? supplies the fallback without eating legitimate falsy values. Together they replaced pyramids of x && x.y && x.y.z guards. Related assignment shorthands: x ??= v (assign if nullish — lazy defaults), plus ||=/&&=.

One discipline: optional chaining tolerates absence — it doesn’t explain it. At trust boundaries (API responses), prefer validating the shape once (schema check) over sprinkling ?. through fifty call sites; a payload that’s wrong should fail loudly at arrival, not propagate undefined quietly.


Map and Set: When Plain Objects Aren’t the Right Dictionary

Objects moonlight as key-value stores, but Map is the purpose-built one:

const cache = new Map();
cache.set(userObj, computedProfile); // ANY key type — objects, not just strings
cache.get(userObj);
cache.size; // real size, no Object.keys().length
for (const [k, v] of cache) { ... } // directly iterable, insertion order
const seen = new Set();
seen.add(id);
seen.has(id); // O(1) membership — the point of sets

Choose Map when keys are dynamic/unknown (user-generated IDs, object keys), when you add/remove a lot, or when size and iteration matter — it also dodges object pitfalls like the prototype chain ("toString" in obj is true for {}) and key coercion (object keys are stringified: obj[1] and obj["1"] collide; Map distinguishes them). Choose plain objects for fixed, known shapes — records with named fields, i.e., most data — where literal syntax, destructuring, and JSON round-tripping are the daily win (Map doesn’t JSON-serialize without conversion). WeakMap/WeakSet add the no-ownership caching behavior — keys don’t keep objects alive — for metadata-attachment cases.

The honest summary: objects for records, Map for dictionaries. Most “should I use Map?” hesitancy dissolves under that sentence.


JSON: The Boundary Format

Objects meet the outside world as JSON, and the conversion pair has edges worth knowing before they bite:

const payload = JSON.stringify(order); // object → string
const parsed = JSON.parse(response); // string → object

What stringify silently does: drops undefined-valued properties, functions, and symbols entirely ({ a: undefined }"{}"); converts Dates to ISO strings (they come back as strings, not Dates — the classic round-trip surprise); turns Map/Set into useless {}; and throws on circular references. What parse never does: revive types — everything returns as plain objects, arrays, strings, numbers, booleans, null.

The practical consequences: define explicit serialization for rich types (a toJSON() method on a class controls its own output), convert Maps via Object.fromEntries(map) before sending, re-hydrate dates deliberately after parsing, and treat parse output as untrusted shape needing validationJSON.parse guarantees syntax, never structure. Two lesser-known second arguments help in a pinch: JSON.stringify(obj, null, 2) pretty-prints for logging, and a replacer/reviver function pair can transform values in transit — though a schema library does that job more maintainably.

One performance note that surfaces in interviews: parse(stringify(x)) as a deep-copy hack is both lossy (all of the above) and slow versus structuredClone — its persistence in tutorials is pure inertia. You know better now.

Frequently Asked Questions

Does Object.freeze give real immutability? Shallowly — top-level writes are ignored (or throw in strict mode), but nested objects stay mutable. Deep-freezing requires recursion, and the ecosystem mostly skips it: convention (never mutate) plus reference-equality tooling has proven sufficient, with TypeScript’s readonly catching violations at compile time.

Spread vs Object.assign? { ...a, ...b } and Object.assign({}, a, b) produce the same result; spread reads better and won. Object.assign(target, src) — with an existing target — mutates it; that’s occasionally wanted, usually not.

How do I remove a key immutably? Destructure-and-rest: const { password, ...safe } = user;safe is a new object minus password. (The mutating counterpart is delete user.password — same reference caveats as ever.)

Why did my two “identical” objects fail ===? Reference equality — different objects, even with equal contents. Compare fields, use a library deepEqual, or restructure to compare identities (a.id === b.id).

Is there a record/tuple with value equality coming? The long-discussed Records & Tuples proposal stalled; as of now, value-equal compound primitives aren’t in the language. Conventions (immutability + id-based identity) and libraries fill the gap.


One More Tool: Getters and Setters on Plain Objects

Object literals (and classes) support computed properties that run code on access — occasionally exactly right:

const cart = {
items: [],
get totalPaise() { // read as cart.totalPaise — no parens
return this.items.reduce((s, i) => s + i.paise, 0);
},
set discount(pct) { // cart.discount = 10 runs validation
if (pct < 0 || pct > 100) throw new RangeError("0-100 only");
this._discount = pct;
},
};

Getters shine for derived values that should always be fresh (totals, full names) — no stale cached field to forget updating. Setters earn their place for validation on assignment. The caution: property access that secretly does heavy work or has side effects violates every reader’s expectations (obj.total looks free; if it’s O(n²), that’s a trap). Keep getters cheap and pure, prefer explicit methods for anything expensive, and note that JSON.stringify does include getter values while spread copies them as plain data — derived state becomes frozen state the moment it’s copied.

Self-Check

const base = { a: 1, nested: { b: 2 } };
// 1
const c1 = { ...base };
c1.a = 10; c1.nested.b = 20;
console.log(base.a, base.nested.b);
// 2
const { nested: { b }, x = 5 } = base;
console.log(b, x);
// 3
const merged = { a: 9, ...base };
console.log(merged.a);
// 4 — write it: swap keys/values of obj in one line
// 5 — what's wrong: const ids = {}; ids[user1] = 1; ids[user2] = 2;

Answers: (1) 1, 20 — top level copied, nested shared: the shallow trap in four lines. (2) 2, 5. (3) 1 — spread later wins; ...base overwrites the earlier a: 9. (4) Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k])). (5) object keys stringify — both users become "[object Object]", one entry, silent collision. That’s a Map.

Next: how files became components — ES Modules: import/export semantics, default-vs-named debates, dynamic import, and how bundlers see your dependency graph.