Emmanuel Akinfulubi

Data structures all 4 written

Arrays, objects, Map and Set, and the copying and aliasing rules that decide whether your data changes under you.

13 Arrays, and the methods that change them

An array is an ordered list of values. The ideas are simple; the difficulty is that some array methods change the array you call them on and others hand you a new one, and JavaScript does not make the difference obvious from the name.

Making one, reading it

Indexing

ran in node · runs here too

example.mjs

const colours = ["red", "green", "blue"];

console.log(colours[0], colours[2], colours[3]);   // positions start at 0
console.log(colours.at(-1), colours.length);
console.log(colours.includes("green"), colours.indexOf("blue"), colours.indexOf("pink"));

check("reading past the end is undefined, not an error", colours[3], undefined);
check("at(-1) is the last item", colours.at(-1), "blue");

What node printed, when the build ran it

node example.mjs

red blue undefined
blue 3
true 2 -1
ok   reading past the end is undefined, not an error
ok   at(-1) is the last item

Adding and removing at the ends

push, pop, unshift, shift

ran in node · runs here too

example.mjs

const queue = ["b", "c"];

queue.push("d");          // add to the end
queue.unshift("a");       // add to the start
console.log(queue);

const last = queue.pop();     // remove from the end
const first = queue.shift();  // remove from the start
console.log(first, last, queue);

check("push and pop work at the end", last, "d");
check("unshift and shift work at the start", first, "a");
check("and all four changed the array itself", queue, ["b", "c"]);

What node printed, when the build ran it

node example.mjs

[ 'a', 'b', 'c', 'd' ]
a d [ 'b', 'c' ]
ok   push and pop work at the end
ok   unshift and shift work at the start
ok   and all four changed the array itself

Changes the array, or makes a new one?

This is the question to ask about every array method. Here are the common ones, run on a fresh copy each time, sorted by the answer.

Mutating versus non-mutating

ran in node · runs here too

example.mjs

const original = [3, 1, 2];
const run = (name, fn) => {
  const copy = [...original];
  const result = fn(copy);
  const changed = copy.join() !== original.join();
  console.log(name.padEnd(28), changed ? "CHANGES the array " : "leaves it alone    ", JSON.stringify(result));
  return changed;
};

const mutators = [
  run("push(4)", (a) => a.push(4)),
  run("sort()", (a) => a.sort()),
  run("reverse()", (a) => a.reverse()),
  run("splice(0, 1)", (a) => a.splice(0, 1)),
  run("fill(0)", (a) => a.fill(0)),
];
console.log();
const pure = [
  run("slice(0, 1)", (a) => a.slice(0, 1)),
  run("map(n => n * 2)", (a) => a.map((n) => n * 2)),
  run("concat([4])", (a) => a.concat([4])),
  run("toSorted()", (a) => a.toSorted()),
  run("toReversed()", (a) => a.toReversed()),
  run("toSpliced(0, 1)", (a) => a.toSpliced(0, 1)),
  run("with(0, 9)", (a) => a.with(0, 9)),
];

check("these five change the array they are called on", mutators.every(Boolean), true);
check("these seven never do", pure.some(Boolean), false);

What node printed, when the build ran it

node example.mjs

push(4)                      CHANGES the array  4
sort()                       CHANGES the array  [1,2,3]
reverse()                    CHANGES the array  [2,1,3]
splice(0, 1)                 CHANGES the array  [3]
fill(0)                      CHANGES the array  [0,0,0]

slice(0, 1)                  leaves it alone     [3]
map(n => n * 2)              leaves it alone     [6,2,4]
concat([4])                  leaves it alone     [3,1,2,4]
toSorted()                   leaves it alone     [1,2,3]
toReversed()                 leaves it alone     [2,1,3]
toSpliced(0, 1)              leaves it alone     [1,2]
with(0, 9)                   leaves it alone     [9,1,2]
ok   these five change the array they are called on
ok   these seven never do

Two things to notice. push returns the new length, not the array — so const list = items.push(x) gives you a number. And the to… methods, which are recent additions, exist precisely so you can sort, reverse or edit an array without destroying the original.

splice and slice

One letter apart, opposite behaviour

ran in node · runs here too

example.mjs

const letters = ["a", "b", "c", "d", "e"];

console.log(letters.slice(1, 3));          // copy positions 1 up to (not including) 3
console.log(letters);                      // unchanged

const removed = letters.splice(1, 2, "X"); // at 1, remove 2, insert "X"
console.log(removed, letters);             // changed

check("slice copies a range", ["a", "b", "c", "d", "e"].slice(1, 3), ["b", "c"]);
check("splice returns what it removed", removed, ["b", "c"]);
check("and edits the array in place", letters, ["a", "X", "d", "e"]);

What node printed, when the build ran it

node example.mjs

[ 'b', 'c' ]
[ 'a', 'b', 'c', 'd', 'e' ]
[ 'b', 'c' ] [ 'a', 'X', 'd', 'e' ]
ok   slice copies a range
ok   splice returns what it removed
ok   and edits the array in place

Copying is not the same as assigning

Two names versus two arrays

ran in node · runs here too

example.mjs

const a = [1, 2, 3];
const alias = a;         // the same array, under another name
const copy = [...a];     // a new array with the same items

a.push(4);

console.log(alias, copy);

check("the alias saw the push", alias, [1, 2, 3, 4]);
check("the copy did not", copy, [1, 2, 3]);

What node printed, when the build ran it

node example.mjs

[ 1, 2, 3, 4 ] [ 1, 2, 3 ]
ok   the alias saw the push
ok   the copy did not

Assigning an array to a new name does not copy it. Both names refer to one array, and a change through either is visible through both. Lesson 14 is about exactly this, for objects in general.

Building arrays

Array.from, flat, flatMap

ran in node · runs here too

example.mjs

console.log(Array.from({ length: 4 }, (_, i) => i * i));
console.log(Array.from("héllo"));
console.log(new Array(3));
console.log([[1, 2], [3], [4, [5]]].flat());
console.log(["a b", "c d"].flatMap((s) => s.split(" ")));

check("Array.from with a function builds and fills", Array.from({ length: 4 }, (_, i) => i * i), [0, 1, 4, 9]);
check("flat removes one level of nesting", [[1, 2], [3]].flat(), [1, 2, 3]);

What node printed, when the build ran it

node example.mjs

[ 0, 1, 4, 9 ]
[ 'h', 'é', 'l', 'l', 'o' ]
[ <3 empty items> ]
[ 1, 2, 3, 4, [ 5 ] ]
[ 'a', 'b', 'c', 'd' ]
ok   Array.from with a function builds and fills
ok   flat removes one level of nesting

new Array(3) makes an array with a length of 3 and nothing in it — three holes, which is what <3 empty items> means. Holes are skipped by map and forEach, which is surprising enough that it is best to never create them: use Array.from({ length: n }, ...) instead.

The fill trap

fill() with an object

ran in node · runs here too

example.mjs

const grid = new Array(3).fill([]);   // looks like three empty rows

grid[0].push("x");

console.log(grid);

check("all three rows are the same array", grid[0] === grid[2], true);

What node printed, when the build ran it

node example.mjs

[ [ 'x' ], [ 'x' ], [ 'x' ] ]
ok   all three rows are the same array

The fix: a function that makes each one

ran in node · runs here too

example.mjs

const grid = Array.from({ length: 3 }, () => []);   // a new array for each row

grid[0].push("x");

console.log(grid);

check("each row is its own array", grid[0] === grid[2], false);
check("so only the first row changed", grid, [["x"], [], []]);

What node printed, when the build ran it

node example.mjs

[ [ 'x' ], [], [] ]
ok   each row is its own array
ok   so only the first row changed

fill(value) puts the same value in every slot. For a number that is fine. For an array or object it is one object, shared three times, and pushing to "one row" pushes to all of them. The function form of Array.from calls the function once per slot, so each gets a new one.

Is it an array?

Array.isArray

ran in node · runs here too

example.mjs

console.log(typeof [], Array.isArray([]), Array.isArray({ length: 0 }));

check("typeof cannot tell you", typeof [], "object");
check("Array.isArray can", Array.isArray([]), true);

What node printed, when the build ran it

node example.mjs

object true false
ok   typeof cannot tell you
ok   Array.isArray can
  • Positions start at 0. Reading past the end gives undefined. at(-1) is the last item.
  • Ask of every method: does it change the array? push pop shift unshift splice sort reverse fill do.
  • slice map filter concat and toSorted toReversed toSpliced with return a new array instead.
  • Assigning an array does not copy it. [...a] does (one level deep).
  • Avoid holes: Array.from({ length: n }, fn), not new Array(n).
  • fill([]) shares one array across every slot. Use the function form.

14 Objects, references and copying

An object groups named values together — a user's name and email, a server's host and port. It is the structure most of your data will live in, and it comes with one rule that explains every surprising thing it does: a variable does not hold an object, it holds a reference to one.

Making and reading objects

An object

ran in node · runs here too

example.mjs

const key = "favourite colour";
const name = "Ada";

const person = {
  name,                         // shorthand for name: name
  born: 1815,
  [key]: "green",               // a computed key
  describe() {                  // a method
    return `${this.name}, born ${this.born}`;
  },
};

console.log(person.name, person["favourite colour"], person[key]);
console.log(person.describe());

person.email = "ada@example.com";   // add a property
delete person.born;                  // remove one
console.log(Object.keys(person));

check("bracket notation reaches any key", person["favourite colour"], "green");
check("properties can be added and removed", Object.keys(person), ["name", "favourite colour", "describe", "email"]);

What node printed, when the build ran it

node example.mjs

Ada green green
Ada, born 1815
[ 'name', 'favourite colour', 'describe', 'email' ]
ok   bracket notation reaches any key
ok   properties can be added and removed

Use the dot when you know the key while writing the code. Use brackets when the key is in a variable, or is not a valid name — anything with a space or a dash in it.

Does it have this property?

in, hasOwn, and undefined

ran in node · runs here too

example.mjs

const config = { port: 8080, host: undefined };

console.log(config.host === undefined, config.debug === undefined);   // cannot tell them apart
console.log("host" in config, "debug" in config);
console.log(Object.hasOwn(config, "host"), Object.hasOwn(config, "toString"));
console.log("toString" in config, "<- in also sees inherited properties");

check("in: the key exists, even holding undefined", "host" in config, true);
check("hasOwn ignores what the object inherits", Object.hasOwn(config, "toString"), false);

What node printed, when the build ran it

node example.mjs

true true
true false
true false
true <- in also sees inherited properties
ok   in: the key exists, even holding undefined
ok   hasOwn ignores what the object inherits

Key order is not always insertion order

Integer keys jump the queue

ran in node · runs here too

example.mjs

const sizes = { large: 3, "10": "ten", small: 1, "2": "two", medium: 2 };

console.log(Object.keys(sizes));

check("integer-like keys go first, in number order", Object.keys(sizes), ["2", "10", "large", "small", "medium"]);

What node printed, when the build ran it

node example.mjs

[ '2', '10', 'large', 'small', 'medium' ]
ok   integer-like keys go first, in number order

Keys that look like whole numbers are always listed first, in numeric order, then every other key in the order it was added. It rarely matters — until you build a lookup keyed by codes like "404" and "500" and wonder why it does not print in the order you wrote it. If order matters, use an array, or a Map (next lesson).

Variables hold references

A function changes its caller’s data

ran in node · runs here too

example.mjs

function applyDiscount(order) {
  order.total = order.total * 0.9;     // changes the caller's object
  return order;
}

const mine = { id: 1, total: 100 };
const result = applyDiscount(mine);

console.log(mine, result === mine);

check("the function changed the object it was given", mine.total, 90);
check("and returned the very same object", result === mine, true);

What node printed, when the build ran it

node example.mjs

{ id: 1, total: 90 } true
ok   the function changed the object it was given
ok   and returned the very same object

When you pass an object to a function, the function receives a reference to the same object — not a copy. Anything it changes, the caller sees. That is often exactly what you want, and it is the cause of a whole category of bugs where data changes "by itself". The fix, when you do not want it, is to build a new object instead of editing the old one.

Returning a changed copy instead

ran in node · runs here too

example.mjs

function withDiscount(order) {
  return { ...order, total: order.total * 0.9 };   // a new object; the original is untouched
}

const mine = { id: 1, total: 100 };
const discounted = withDiscount(mine);

console.log(mine, discounted);

check("the original is unchanged", mine.total, 100);
check("the new object has the discount", discounted.total, 90);

What node printed, when the build ran it

node example.mjs

{ id: 1, total: 100 } { id: 1, total: 90 }
ok   the original is unchanged
ok   the new object has the discount

A spread is a shallow copy

One level deep, and no deeper

ran in node · runs here too

example.mjs

const original = { name: "Ada", address: { city: "London" } };
const copy = { ...original };

copy.name = "Grace";               // top level: copied
copy.address.city = "New York";    // nested: shared

console.log(original);
console.log(copy);

check("top-level properties were copied", original.name, "Ada");
check("the nested object is shared by both", original.address === copy.address, true);
check("so changing it through the copy changed the original", original.address.city, "New York");

What node printed, when the build ran it

node example.mjs

{ name: 'Ada', address: { city: 'New York' } }
{ name: 'Grace', address: { city: 'New York' } }
ok   top-level properties were copied
ok   the nested object is shared by both
ok   so changing it through the copy changed the original

{ ...obj } and Object.assign({}, obj) copy the top level. Any property that is itself an object is copied as a reference, so the "copy" and the original share it. For a copy that goes all the way down, use structuredClone.

structuredClone, a real deep copy

ran at build time

example.mjs

const original = {
  name: "Ada",
  address: { city: "London" },
  joined: new Date("2020-01-15T00:00:00Z"),
  tags: new Set(["admin"]),
};
const deep = structuredClone(original);

deep.address.city = "New York";
deep.tags.add("owner");

console.log(original.address.city, [...original.tags]);
console.log(deep.address.city, [...deep.tags]);
console.log(deep.joined instanceof Date);

check("nothing is shared any more", original.address.city, "London");
check("a Set was deep-copied too", original.tags.has("owner"), false);
check("and a Date stayed a Date", deep.joined instanceof Date, true);

What node printed, when the build ran it

node example.mjs

London [ 'admin' ]
New York [ 'admin', 'owner' ]
true
ok   nothing is shared any more
ok   a Set was deep-copied too
ok   and a Date stayed a Date

The old trick, JSON.parse(JSON.stringify(x)), is not a deep copy. It drops undefined and functions, turns Dates into strings, turns Maps and Sets into {}, and throws on a cycle. Lesson 16 runs all of that. structuredClone handles every one of those correctly, and is built into Node and every current browser.

freeze is shallow too

Object.freeze, one level deep

ran in node · runs here too

example.mjs

const settings = Object.freeze({ theme: "dark", limits: { upload: 10 } });

let refused;
try {
  settings.theme = "light";
} catch (err) {
  refused = err.name;
}
settings.limits.upload = 999;     // the nested object was never frozen

console.log(settings, refused);

check("the top level is read-only", settings.theme, "dark");
check("writing to it throws in a module", refused, "TypeError");
check("but the nested object is not frozen", settings.limits.upload, 999);

What node printed, when the build ran it

node example.mjs

{ theme: 'dark', limits: { upload: 999 } } TypeError
ok   the top level is read-only
ok   writing to it throws in a module
ok   but the nested object is not frozen

Transforming an object

Objects do not have map and filter, but they convert to and from arrays of [key, value] pairs, and arrays do.

entries, and back

ran in node · runs here too

example.mjs

const pricesInPence = { tea: 250, cake: 400, water: 0 };

const inPounds = Object.fromEntries(
  Object.entries(pricesInPence)
    .filter(([, p]) => p > 0)
    .map(([item, p]) => [item, p / 100]),
);

console.log(inPounds);

check("entries -> filter/map -> fromEntries", inPounds, { tea: 2.5, cake: 4 });

What node printed, when the build ran it

node example.mjs

{ tea: 2.5, cake: 4 }
ok   entries -> filter/map -> fromEntries

Comparing two objects

Identity versus contents

ran at build time

example.mjs

import { isDeepStrictEqual } from "node:util";

const a = { id: 1, tags: ["x"] };
const b = { id: 1, tags: ["x"] };

console.log(a === b, isDeepStrictEqual(a, b));

check("=== asks: the same object?", a === b, false);
check("isDeepStrictEqual asks: the same contents?", isDeepStrictEqual(a, b), true);

What node printed, when the build ran it

node example.mjs

false true
ok   === asks: the same object?
ok   isDeepStrictEqual asks: the same contents?

That is the comparison every check() on this site uses. Node also has it in node:assert as assert.deepStrictEqual, which is what the testing lessons use.

The error you will see most

dies with TypeError

example.mjs

const user = null;          // the lookup found nobody

console.log(user.name);

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:3
console.log(user.name);
                 ^

TypeError: Cannot read properties of null (reading 'name')
    at file:///app/example.mjs:3:18
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0
  • Dot for known names; brackets for keys in variables or with spaces. in and Object.hasOwn tell a missing key from one holding undefined.
  • Integer-like keys are listed first, in numeric order.
  • Variables hold references. A function given an object can change it for the caller.
  • { ...obj } is a shallow copy; nested objects are shared. structuredClone copies all the way down.
  • Object.freeze is shallow too.
  • Object.entries and Object.fromEntries let you use array methods on an object.

15 Map and Set, and when a plain object is wrong

A plain object is a good record — a user, a config — where you know the keys in advance. It is a bad dictionary, where the keys come from data. Map is the dictionary, and Set is the collection of unique values, and both exist because a plain object gets those jobs subtly wrong.

What goes wrong with a plain object

A word counter, with one unlucky word

ran in node · runs here too

example.mjs

function countWords(text) {
  const counts = {};
  for (const word of text.split(" ")) {
    counts[word] = (counts[word] || 0) + 1;
  }
  return counts;
}

const counts = countWords("the constructor calls the builder");
console.log(counts.the, counts.builder);
console.log(typeof counts.constructor, "starting", JSON.stringify(counts.constructor.slice(0, 15)));

check("ordinary words count fine", counts.the, 2);
check("but 'constructor' was already there", typeof counts.constructor, "string");
check("so its count is garbage", counts.constructor.endsWith("1"), true);

What node printed, when the build ran it

node example.mjs

2 1
string starting "function Object"
ok   ordinary words count fine
ok   but 'constructor' was already there
ok   so its count is garbage

A plain object is not empty: it inherits properties such as constructor and toString from Object.prototype (lesson 17). So counts["constructor"] starts out as a function, || 0 keeps it, and adding 1 to a function converts it to its source text — function Object() { [native code] } — and appends "1". Any program that uses words from users as object keys has this bug somewhere.

Every key becomes a string

ran in node · runs here too

example.mjs

const byId = {};
byId[1] = "one";
byId["1"] = "the string one";

const a = { id: "a" };
const b = { id: "b" };
const labels = {};
labels[a] = "first object";
labels[b] = "second object";

console.log(byId, Object.keys(labels));

check("the number 1 and the string '1' are the same key", Object.keys(byId), ["1"]);
check("every object key becomes the same string", Object.keys(labels), ["[object Object]"]);

What node printed, when the build ran it

node example.mjs

{ '1': 'the string one' } [ '[object Object]' ]
ok   the number 1 and the string '1' are the same key
ok   every object key becomes the same string

Map

The same counter, with a Map

ran in node · runs here too

example.mjs

function countWords(text) {
  const counts = new Map();
  for (const word of text.split(" ")) {
    counts.set(word, (counts.get(word) ?? 0) + 1);
  }
  return counts;
}

const counts = countWords("the constructor calls the builder");
console.log(counts);
console.log(counts.get("constructor"), counts.size);

check("Map starts genuinely empty", countWords("constructor").get("constructor"), 1);
check("size is a property, not a loop", counts.size, 4);

What node printed, when the build ran it

node example.mjs

Map(4) { 'the' => 2, 'constructor' => 1, 'calls' => 1, 'builder' => 1 }
1 4
ok   Map starts genuinely empty
ok   size is a property, not a loop

Any value can be a key

ran in node · runs here too

example.mjs

const a = { id: "a" };
const b = { id: "b" };

const labels = new Map([
  [a, "first object"],
  [b, "second object"],
  [1, "the number one"],
  ["1", "the string one"],
]);

console.log(labels.get(a), labels.get(b));
console.log(labels.get(1), labels.get("1"));
console.log(labels.has(a), labels.delete(b), labels.has(b));

check("objects work as keys, by identity", labels.get(a), "first object");
check("1 and '1' are different keys", labels.get(1) === labels.get("1"), false);

What node printed, when the build ran it

node example.mjs

first object second object
the number one the string one
true true false
ok   objects work as keys, by identity
ok   1 and '1' are different keys

Iterating, and converting

ran in node · runs here too

example.mjs

const scores = new Map([["ada", 91], ["alan", 78], ["grace", 88]]);

for (const [name, score] of scores) {
  console.log(name.padEnd(6), score);
}

const asObject = Object.fromEntries(scores);        // Map -> object, for JSON
const back = new Map(Object.entries(asObject));     // and back

console.log(JSON.stringify(asObject));

check("a Map keeps insertion order", [...scores.keys()], ["ada", "alan", "grace"]);
check("and round-trips through an object", back.get("grace"), 88);

What node printed, when the build ran it

node example.mjs

ada    91
alan   78
grace  88
{"ada":91,"alan":78,"grace":88}
ok   a Map keeps insertion order
ok   and round-trips through an object

Which one? A plain object when the keys are fixed and known in the code — a record, a config, something you will turn into JSON. A Map when keys come from data, are not strings, or are added and removed as the program runs. One catch: JSON.stringify turns a Map into {}, so convert with Object.fromEntries first.

Set

Unique values

ran in node · runs here too

example.mjs

const visits = ["home", "about", "home", "blog", "about", "home"];
const unique = new Set(visits);

console.log(unique, unique.size);
console.log([...unique]);
console.log(unique.has("blog"), unique.has("shop"));

check("a Set keeps one of each", [...unique], ["home", "about", "blog"]);
check("the shortest way to de-duplicate an array", [...new Set([3, 1, 3, 2, 1])], [3, 1, 2]);

What node printed, when the build ran it

node example.mjs

Set(3) { 'home', 'about', 'blog' } 3
[ 'home', 'about', 'blog' ]
true false
ok   a Set keeps one of each
ok   the shortest way to de-duplicate an array

Set operations

ran in node · runs here too

example.mjs

const frontend = new Set(["ada", "grace", "linus"]);
const backend = new Set(["alan", "grace", "linus"]);

console.log(frontend.union(backend));
console.log(frontend.intersection(backend));
console.log(frontend.difference(backend));
console.log(frontend.isSubsetOf(new Set(["ada", "grace", "linus", "ken"])));

check("people on both teams", [...frontend.intersection(backend)], ["grace", "linus"]);
check("frontend only", [...frontend.difference(backend)], ["ada"]);

What node printed, when the build ran it

node example.mjs

Set(4) { 'ada', 'grace', 'linus', 'alan' }
Set(2) { 'grace', 'linus' }
Set(1) { 'ada' }
true
ok   people on both teams
ok   frontend only

Why "has" is the point of a Set

Checking whether an array contains something means looking at every element until you find it. A Set answers the same question by jumping straight to where the value would be, however big the Set is.

includes versus has, timed

ran at build time

example.mjs

const ids = Array.from({ length: 20_000 }, (_, i) => `user-${i}`);
const idSet = new Set(ids);
const lookups = Array.from({ length: 2_000 }, (_, i) => `user-${i * 10}`);

let t = performance.now();
const inArray = lookups.filter((id) => ids.includes(id)).length;
const arrayMs = performance.now() - t;

t = performance.now();
const inSet = lookups.filter((id) => idSet.has(id)).length;
const setMs = performance.now() - t;

console.log(`array.includes: ${arrayMs.toFixed(1)} ms`);
console.log(`set.has:        ${setMs.toFixed(2)} ms`);
console.log(`about ${Math.round(arrayMs / setMs)} times faster`);

check("both found the same ids", inArray, inSet);
check("and the Set was far faster", arrayMs > setMs * 10, true);

What node printed, when the build ran it

node example.mjs

array.includes: 101.9 ms
set.has:        0.83 ms
about 123 times faster
ok   both found the same ids
ok   and the Set was far faster

Two thousand lookups in twenty thousand items is small, and the gap is already large. It grows with the size of the list. Whenever you find yourself calling includes inside a loop, that is the moment to build a Set first.

WeakMap, briefly

A WeakMap is a Map whose keys must be objects, and which does not keep those objects alive: when nothing else refers to a key, the entry disappears by itself. It is for attaching extra data to objects you do not own — a cache per request, say — without leaking memory. Lesson 46 comes back to it with the garbage collector.

  • A plain object inherits keys like constructor, and turns every key into a string. It is a record, not a dictionary.
  • Map: any key type, genuinely empty, ordered, with size.
  • Convert a Map with Object.fromEntries before JSON.stringify.
  • Set keeps one of each; [...new Set(arr)] de-duplicates. union, intersection and difference are built in.
  • set.has is much faster than array.includes on anything large.

16 Destructuring, spread and JSON

Most code spends its time taking data apart and putting it back together. Destructuring and spread are the syntax for that, and JSON is the format data travels in between programs. Together they are most of what the next thirty lessons do with data.

Taking an object apart

Object destructuring

ran in node · runs here too

example.mjs

const user = { id: 7, name: "Ada", email: "ada@example.com", role: undefined };

const { name, email } = user;                    // pick properties by name
const { id: userId } = user;                     // rename while picking
const { role = "member" } = user;                // a default, for undefined
const { name: _n, ...withoutName } = user;       // collect everything else

console.log(name, email, userId, role);
console.log(withoutName);

check("destructuring reads properties by name", [name, userId], ["Ada", 7]);
check("a default fills in undefined", role, "member");
check("...rest collects the remaining properties", Object.keys(withoutName), ["id", "email", "role"]);

What node printed, when the build ran it

node example.mjs

Ada ada@example.com 7 member
{ id: 7, email: 'ada@example.com', role: undefined }
ok   destructuring reads properties by name
ok   a default fills in undefined
ok   ...rest collects the remaining properties

In a function's parameters

Destructuring is most useful in a parameter list. A function that takes an options object can name what it wants, give each a default, and ignore the rest.

An options object

ran in node · runs here too

example.mjs

function connect({ host = "127.0.0.1", port = 5432, ssl = false } = {}) {
  return `${ssl ? "tls" : "tcp"}://${host}:${port}`;
}

console.log(connect({ port: 6543 }));
console.log(connect({ host: "db.internal", ssl: true }));
console.log(connect());

check("unnamed options fall back to defaults", connect({ port: 6543 }), "tcp://127.0.0.1:6543");
check("the = {} makes calling with nothing safe", connect(), "tcp://127.0.0.1:5432");

What node printed, when the build ran it

node example.mjs

tcp://127.0.0.1:6543
tls://db.internal:5432
tcp://127.0.0.1:5432
ok   unnamed options fall back to defaults
ok   the = {} makes calling with nothing safe

The = {} at the end is the part people forget. Without it, connect() with no argument tries to destructure undefined and throws. This options-object shape is the way almost every Node API takes settings — fs.mkdir(path, { recursive: true }), http.createServer({ ... }) — so it is worth recognising.

Taking an array apart

Array destructuring

ran in node · runs here too

example.mjs

const [first, second, ...others] = ["gold", "silver", "bronze", "fourth"];
const [, , third] = ["a", "b", "c"];         // skip positions with commas

let x = 1;
let y = 2;
[x, y] = [y, x];                              // swap without a temporary variable

console.log(first, second, others, third, x, y);

const entries = Object.entries({ a: 1, b: 2 });
for (const [key, value] of entries) console.log(key, "=", value);

check("array destructuring is by position", [first, second], ["gold", "silver"]);
check("the swap worked", [x, y], [2, 1]);

What node printed, when the build ran it

node example.mjs

gold silver [ 'bronze', 'fourth' ] c 2 1
a = 1
b = 2
ok   array destructuring is by position
ok   the swap worked

Spread: putting things back together

Merging with spread

ran in node · runs here too

example.mjs

const defaults = { theme: "light", fontSize: 14, beta: false };
const saved = { fontSize: 18 };

const settings = { ...defaults, ...saved };             // later keys win
const reversed = { ...saved, ...defaults };             // so order matters

const base = [1, 2];
const more = [0, ...base, 3];

console.log(settings);
console.log(reversed);
console.log(more);

check("the user's setting overrides the default", settings.fontSize, 18);
check("put the defaults last and they win instead", reversed.fontSize, 14);
check("spread works in arrays too", more, [0, 1, 2, 3]);

What node printed, when the build ran it

node example.mjs

{ theme: 'light', fontSize: 18, beta: false }
{ fontSize: 14, theme: 'light', beta: false }
[ 0, 1, 2, 3 ]
ok   the user's setting overrides the default
ok   put the defaults last and they win instead
ok   spread works in arrays too

JSON

JSON is text that looks like a JavaScript object literal, with stricter rules: keys in double quotes, strings in double quotes, no trailing commas, no comments. It is how data moves between a Node program and a browser, a database, a config file or another service.

stringify and parse

ran in node · runs here too

example.mjs

const order = { id: 42, items: ["tea", "cake"], paid: true, note: null };

const text = JSON.stringify(order);
const pretty = JSON.stringify(order, null, 2);    // indented, for people
const back = JSON.parse(text);

console.log(text);
console.log(pretty);
console.log(back.items[1], back === order);

check("stringify gives a string", typeof text, "string");
check("parse gives back an equal object", back, order);
check("but not the same object", back === order, false);

What node printed, when the build ran it

node example.mjs

{"id":42,"items":["tea","cake"],"paid":true,"note":null}
{
  "id": 42,
  "items": [
    "tea",
    "cake"
  ],
  "paid": true,
  "note": null
}
cake false
ok   stringify gives a string
ok   parse gives back an equal object
ok   but not the same object

What JSON cannot carry

JSON has strings, numbers, booleans, null, arrays and plain objects. Anything else is changed or dropped on the way — and JSON.stringify does not warn you.

Four ways data changes in transit

ran in node · runs here too

example.mjs

const record = {
  name: "Ada",
  nickname: undefined,               // vanishes
  greet() { return "hi"; },          // vanishes
  joined: new Date("2024-03-01T09:00:00Z"),   // becomes a string
  tags: new Set(["admin"]),          // becomes {}
  score: 0 / 0,                      // NaN becomes null
};

const text = JSON.stringify(record);
const back = JSON.parse(text);

console.log(text);
console.log(typeof back.joined, back.tags, back.score);

check("undefined and functions are dropped", "nickname" in back || "greet" in back, false);
check("a Date comes back as a string", typeof back.joined, "string");
check("a Set comes back as an empty object", back.tags, {});
check("NaN comes back as null", back.score, null);

What node printed, when the build ran it

node example.mjs

{"name":"Ada","joined":"2024-03-01T09:00:00.000Z","tags":{},"score":null}
string {} null
ok   undefined and functions are dropped
ok   a Date comes back as a string
ok   a Set comes back as an empty object
ok   NaN comes back as null

Each has a fix. Dates are sent as ISO strings on purpose and turned back into Dates on arrival; Sets and Maps are converted to arrays or objects first; and NaN should never reach the point of being serialised, because once it is null it looks like "no value" rather than "a calculation went wrong".

Reviving a Date

ran in node · runs here too

example.mjs

const text = '{"name":"Ada","joined":"2024-03-01T09:00:00.000Z"}';

const user = JSON.parse(text, (key, value) =>
  key === "joined" ? new Date(value) : value,
);

console.log(user.joined instanceof Date, user.joined.getUTCFullYear());

check("a reviver turns the string back into a Date", user.joined instanceof Date, true);

What node printed, when the build ran it

node example.mjs

true 2024
ok   a reviver turns the string back into a Date

toJSON

ran in node · runs here too

example.mjs

class Money {
  constructor(pence) { this.pence = pence; }
  toJSON() { return (this.pence / 100).toFixed(2); }   // what JSON.stringify should use
}

console.log(JSON.stringify({ price: new Money(1999) }));

check("toJSON controls how an object serialises", JSON.stringify({ p: new Money(250) }), '{"p":"2.50"}');

What node printed, when the build ran it

node example.mjs

{"price":"19.99"}
ok   toJSON controls how an object serialises

When the text is not JSON

JSON that arrives from outside your program — a request body, a file, an API response — can be malformed, and JSON.parse throws when it is.

Single quotes are not JSON

dies with SyntaxError

example.mjs

const body = "{ 'name': 'Ada' }";      // single quotes: not JSON

const user = JSON.parse(body);
console.log(user.name);

What node printed as it died, when the build ran it

node example.mjs

<anonymous_script>:1
{ 'name': 'Ada' }
  ^

SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)
    at JSON.parse (<anonymous>)
    at file:///app/example.mjs:3:19
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

Parsing outside input safely

ran in node · runs here too

example.mjs

function parseOrNull(text) {
  try {
    return JSON.parse(text);
  } catch {
    return null;
  }
}

console.log(parseOrNull('{"ok": true}'), parseOrNull("{ok: true}"), parseOrNull(""));

check("good JSON parses", parseOrNull('{"ok": true}'), { ok: true });
check("bad JSON gives null instead of crashing", parseOrNull("{ok: true}"), null);
check("so does an empty body", parseOrNull(""), null);

What node printed, when the build ran it

node example.mjs

{ ok: true } null null
ok   good JSON parses
ok   bad JSON gives null instead of crashing
ok   so does an empty body
  • Object destructuring picks by name, with renames, defaults and ...rest. Array destructuring picks by position.
  • Destructure an options parameter, with = {} so it can be omitted.
  • { ...a, ...b } merges; later keys win.
  • JSON carries strings, numbers, booleans, null, arrays and plain objects. undefined, functions, Dates, Sets and NaN do not survive intact.
  • A reviver rebuilds values on parse; toJSON controls stringify.
  • Wrap JSON.parse of outside input in try.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.