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
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
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.
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
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
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
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
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
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?
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 filldo. slice map filter concatandtoSorted toReversed toSpliced withreturn a new array instead.- Assigning an array does not copy it.
[...a]does (one level deep). - Avoid holes:
Array.from({ length: n }, fn), notnew 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
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?
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
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
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.
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
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.
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
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.
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
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.
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.
inandObject.hasOwntell a missing key from one holdingundefined. - 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.structuredClonecopies all the way down.Object.freezeis shallow too.Object.entriesandObject.fromEntrieslet 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
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.
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
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
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
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
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
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.
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, withsize.- Convert a Map with
Object.fromEntriesbeforeJSON.stringify. Setkeeps one of each;[...new Set(arr)]de-duplicates.union,intersectionanddifferenceare built in.set.hasis much faster thanarray.includeson 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
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.
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
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
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.
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.
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".
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
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.
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
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 andNaNdo not survive intact. - A reviver rebuilds values on
parse;toJSONcontrolsstringify. - Wrap
JSON.parseof outside input intry.