GUIDE · JavaScript
Objects & destructuring
At a glance
- COPY
- { ...o } shallow; structuredClone(o) deep (plain data; no functions)
- READ
- o?.a?.b (optional chaining), o.a ?? 'default' (nullish — not ||)
- ITERATE
- Object.keys / values / entries / fromEntries; for..in walks inherited
- TRAP
- {} === {} is false; JSON.stringify drops undefined/functions, turns NaN/Infinity into null, loses Map/Set/Date type; key order rules
- SAY
- shallow vs deep copyimmutable updatereference equalityproperty descriptorcomputed keyshorthand property
The idea#
Think of an object like a filing cabinet. Each drawer has a label, the key, and holds one thing, the value. cabinet.permit opens the drawer whose label you already know. cabinet[key] opens the drawer whose label is sitting in a variable. A method is a drawer that holds a function. The cabinet is one object in memory. Two variables can point at that same cabinet.
{ permit: '2024' } === { permit: '2024' } is false. Those are two cabinets that happen to hold similar papers. Equality of objects is reference equality. Same cabinet, true. Similar papers, false.
Destructuring pulls the papers you need onto the desk in one line. const { permit, missing = 'none', folder: { city } } = cabinet opens drawers by name, unpacks a nested folder, and uses 'none' when missing is absent. A default applies when the value is undefined. Rest copies the leftover drawers into a new cabinet. Arrays unpack by position: a bare comma skips a slot, and [p, q] = [q, p] swaps two names. You can unpack right in a parameter list, ({ w, h = w }) => w * h.
A shallow copy, { ...cabinet }, is a new cabinet whose nested folders are still the old ones. An immutable update copies the nested folder too: folder: { ...cabinet.folder, city: 'Dallas' }. structuredClone deep-copies plain data. A function in the cabinet is something it cannot copy. Object.assign is the older merge. Later keys win.
Why it matters#
The usual copy shares the inner folders. Push into a nested array on the copy and the original sees the push. Spread the level you mean to change. Object.freeze locks the top drawers. In strict mode, assigning a frozen property throws. The folder inside stays writable. Freeze is shallow.
?. opens a drawer only when the cabinet in front of it exists, so user.address?.zip?.code is undefined when a drawer along the way is missing. ?? treats only null and undefined as missing. || also replaces 0, '', and false. ??= assigns only in the missing case. ||= uses the wider test. A retry count of 0 is a real setting. Keep it with ??.
Existence is three questions. 'name' in user and for...in walk inherited drawers. Object.hasOwn asks about this cabinet. Reading undefined happens both for a missing drawer and for a drawer that holds undefined on purpose. List your own labels with Object.keys, Object.values, and Object.entries.
JSON is a shipping box with rules. JSON.stringify drops undefined and functions, turns NaN and Infinity into null, writes a Date as an ISO string, and turns a Map or a Set into {}. Key order is part of that string. A replacer array is an allowlist. A reviver runs on the way back through JSON.parse. Broken text throws SyntaxError.
A computed key uses brackets: { [key]: 1 }. A shorthand property is { name } when the variable and the label match. A property descriptor says whether a drawer is enumerable, writable, and configurable. Object.defineProperty can hide a value from Object.keys. Getters and setters look like drawers and run code on the way through.
A small example#
const cabinet = { permit: '2024', folder: { city: 'Austin' },}; const { permit, missing = 'none', folder: { city } } = cabinet; const moved = { ...cabinet, permit: '2025', folder: { ...cabinet.folder, city: 'Dallas' },}; const twins = { permit: '2024' } === { permit: '2024' };const retries = 0;const used = retries ?? 2;permit is '2024', missing is 'none', and city is 'Austin'. moved.permit is '2025', while cabinet.permit stays '2024' and cabinet.folder.city stays 'Austin'. The nested folder was copied before the city changed. twins is false: two cabinets, similar papers. used is 0, because ?? keeps a real zero. retries || 2 produces 2.
What the code below is doing#
The code below is nine sections. One user cabinet sits open for every section: id, name, tags, and a nested address.
Destructuring pulls name, a default role, a nested city, and a renamed id, then rest, an array skip, a default over an explicit undefined, a swap, a parameter unpack, and Object.entries. Optional chaining covers a property, a call, and an index. ?? sits beside ||, and ??= leaves a retries of 0 alone while ||= fills timeout.
Spread builds an immutable update: a new name, and a copied address before the city changes. Later keys win. structuredClone keeps a pushed tag off the original. Object.assign is the older merge. Keys cover a computed name, shorthand, a method, then mapValues, pick, and invertObj. Existence uses in, hasOwn, a present key whose value is undefined, and delete. Equality checks reference ===, key order inside JSON.stringify, and a deep compare. JSON drops undefined and a function, turns NaN into null, writes a Date as text, turns an undefined array element into null, and writes a Map as {}. Freeze is shallow. Getters and setters convert Fahrenheit, and a non-enumerable property stays out of Object.keys.
The header's trap: {} === {} is false. JSON.stringify drops undefined and functions, turns NaN and Infinity into null, and loses Map, Set, and Date as types. Key order is part of that string. Say shallow versus deep copy, immutable update, reference equality, property descriptor, computed key, and shorthand property.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict'); const user = { id: 7, name: 'Ada', tags: ['x'], address: { city: 'NYC' } };Destructuring: defaults, rename, nesting, rest, skipping, swap, in params#
const { name, role = 'guest', address: { city }, id: userId } = user;const { tags, ...withoutTags } = user;const [first, , third = 'dflt', ...rest] = [1, 2, undefined, 4, 5];let p = 1, q = 2;[p, q] = [q, p];const area = ({ w, h = w }) => w * h; // destructure in the parameter listconst [[r, c]] = [[1, 2]]; // nested array destructurefor (const [k, v] of Object.entries({ a: 1 })) assert.equal(`${k}${v}`, 'a1');Verified by 7 asserts
assert.deepEqual([name, role, city, userId], ['Ada', 'guest', 'NYC', 7]);assert.deepEqual(tags, ['x']);assert.equal('tags' in withoutTags, false);assert.deepEqual([first, third, rest], [1, 'dflt', [4, 5]]);assert.deepEqual([p, q], [2, 1]);assert.equal(area({ w: 3 }), 9);assert.equal(r + c, 3);?. and ?? (?? treats only null/undefined as missing; || also 0, '', false)#
let cfg = { retries: 0 };cfg.retries ??= 3; // only assigns if null/undefinedcfg.timeout ||= 1000;Verified by 8 asserts
assert.equal(user.address?.zip?.code, undefined);assert.equal(user.getName?.(), undefined); // optional callassert.equal(user.tags?.[0], 'x'); // optional indexassert.equal(user.missing ?? 'd', 'd');assert.equal(0 ?? 5, 0);assert.equal(0 || 5, 5);assert.equal('' ?? 'x', '');assert.deepEqual(cfg, { retries: 0, timeout: 1000 });Spread / merge / immutable update#
const updated = { ...user, name: 'Grace', address: { ...user.address, city: 'LA' } };const deep = structuredClone(user);deep.tags.push('y');Verified by 6 asserts
assert.equal(updated.name, 'Grace');assert.equal(user.name, 'Ada'); // original untouchedassert.equal(user.address.city, 'NYC');assert.deepEqual({ ...{ a: 1, b: 2 }, ...{ b: 3 } }, { a: 1, b: 3 }); // later winsassert.deepEqual(user.tags, ['x']);assert.equal(Object.assign({}, { a: 1 }).a, 1); // older spelling of spread-mergeKeys / computed keys / shorthand / methods#
const key = 'dyn';const o = { [key]: 1, [`${key}2`]: 2, name, greet() { return `hi ${this.name}`; } };const mapValues = (obj, f) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, f(v)]));const pick = (obj, ...ks) => Object.fromEntries(ks.filter(k => k in obj).map(k => [k, obj[k]]));const invertObj = obj => Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));Verified by 8 asserts
assert.deepEqual(Object.keys(o), ['dyn', 'dyn2', 'name', 'greet']);assert.equal(o.greet(), 'hi Ada');assert.deepEqual(Object.values({ a: 1, b: 2 }), [1, 2]);assert.deepEqual(Object.entries({ a: 1 }).map(([k, v]) => `${k}=${v}`), ['a=1']);assert.deepEqual(Object.fromEntries([['a', 1], ['b', 2]]), { a: 1, b: 2 });assert.deepEqual(mapValues({ a: 1, b: 2 }, v => v * 10), { a: 10, b: 20 });assert.deepEqual(pick(user, 'id', 'nope'), { id: 7 });assert.deepEqual(invertObj({ a: 'x' }), { x: 'a' });Existence checks#
const isEmpty = obj => Object.keys(obj).length === 0;delete o.dyn2;Verified by 6 asserts
assert.equal('name' in user, true);assert.equal(Object.hasOwn(user, 'name'), true);assert.equal(user.nope === undefined, true); // but a key can hold undefined explicitlyassert.equal(Object.keys({ a: undefined }).length, 1);assert.equal(isEmpty({}), true);assert.equal('dyn2' in o, false);Equality is by reference; compare by value via JSON (ordered) or deepEqual#
const sameShallow = (x, y) => Object.keys(x).length === Object.keys(y).length && Object.keys(x).every(k => x[k] === y[k]);Verified by 4 asserts
assert.equal({ a: 1 } === { a: 1 }, false);assert.equal(JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 }), false); // order mattersassert.deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // deepEqual doesn't careassert.equal(sameShallow({ a: 1, b: 2 }, { b: 2, a: 1 }), true);JSON edge cases#
Verified by 8 asserts
assert.equal(JSON.stringify({ a: undefined, f() {}, n: NaN, d: new Date(0) }), '{"n":null,"d":"1970-01-01T00:00:00.000Z"}');assert.equal(JSON.stringify([undefined]), '[null]');assert.equal(JSON.stringify(new Map([[1, 2]])), '{}'); // Map/Set serialize to {}assert.equal(JSON.stringify({ b: 1, a: 2 }, null, 0), '{"b":1,"a":2}');assert.equal(JSON.stringify({ b: 1, a: 2 }, ['a']), '{"a":2}'); // replacer allowlistassert.deepEqual(JSON.parse('{"x":"1"}', (k, v) => (k === 'x' ? Number(v) : v)), { x: 1 }); // reviverassert.throws(() => JSON.parse('{bad'), SyntaxError);assert.equal(JSON.stringify('he said "hi"'), '"he said \\"hi\\""');Freezing / sealing (shallow)#
const frozen = Object.freeze({ a: 1, inner: { b: 2 } });frozen.inner.b = 3; // shallow: nested still mutableVerified by 3 asserts
assert.throws(() => { frozen.a = 2; }, TypeError); // throws only in strict modeassert.equal(frozen.inner.b, 3);assert.equal(Object.isFrozen(frozen), true);Getters / setters / defineProperty#
const temp = { _c: 0, get f() { return this._c * 9 / 5 + 32; }, set f(v) { this._c = (v - 32) * 5 / 9; },};temp.f = 212;Object.defineProperty(temp, 'hidden', { value: 42, enumerable: false });Verified by 3 asserts
assert.equal(temp._c, 100);assert.equal(temp.hidden, 42);assert.equal(Object.keys(temp).includes('hidden'), false);See also