Classic utilities
Build production-grade deepClone, deepEqual, curry, compose, memoize, once, flattenObject, unflatten, and get utilities with edge-case tests.
- 01Write the rules before the codeList the inputs and edge cases for
deepClone,deepEqual,curry,memoize, and the path helpers, and turn each rule into a test. - 02Handle the unusual inputs on purposeDecide what happens to Dates, RegExps, Maps, Sets, cycles, placeholders, saved results, empty objects, and missing paths.
- 03Choose a built-in, a library, or your own helperCompare your helpers with
structuredClone, a JSON round trip, spread, Lodash, and Ramda, and pick the simplest one that fits.
Small helpers with clear rules
A utility is a small function you can reuse in many places. Each classic utility has one job:
deepClonecopies a value and every object inside it.deepEqualchecks whether two values hold the same data.currylets you pass a function's arguments a few at a time.composeandpipejoin small functions into one.memoizesaves a result, so the same call doesn't do the work twice.oncelets a function run only one time.flattenObject,unflatten, andgetturn nested objects into paths like"user.name"and back.
Each job sounds simple. The hard part is the unusual inputs, called edge cases. Examples are an object that points to itself, the value NaN, an empty array, or a key with a dot in it. A good utility says what it does with each one.
You met the ideas behind these helpers in earlier lessons: advanced recursion, currying, composition, memoization, closures, WeakMap and WeakSet, messaging and cloning, and Object.keys, values, entries. The polyfill array methods lesson builds array helpers the same way. This lesson puts the ideas together. For each helper you will:
- Write its rules: which inputs it accepts, and what it gives back for each one.
- Build a simple version and watch it fail on an edge case.
- Fix it.
- Prove each rule with a test that runs real JavaScript.
A classic utility is a small helper function with written rules for its edge cases. The written rules are called its contract. Tests that check each rule are called conformance tests. A name like deep or smart doesn't tell you the rules. The contract does.
Picture a shared toolbox. The label on a saw only tells you its name. The safety card next to it tells you what the saw can cut, what it must not touch, and how to check the cut. Then anyone can use the saw safely.
- In real life: The label on a tool
- In JavaScript: The function name, like
deepClone - In real life: The safety card
- In JavaScript: The contract: which inputs work, and what happens to each
- In real life: Picking the right tool for the job
- In JavaScript: Choosing a built-in, a library, or your own helper
- In real life: The inspection stamp
- In JavaScript: Tests that run real JavaScript
JavaScript has several ways to copy data, and each one handles edge cases differently. The table compares four of them:
deepCloneis the helper you build in this lesson.structuredCloneis built into browsers and Node.js.- A JSON round trip is
JSON.parse(JSON.stringify(value)). It turns the value into text, then back into a value. - Spread is
{ ...value }. It copies only the top level.
Read each row as: "when I copy this kind of value, here is what each way gives me."
| What you copy | deepClone | structuredClone | JSON round trip | Spread |
|---|---|---|---|---|
| Nested plain data | A full deep copy. | A full deep copy. | A full deep copy, if the data is plain JSON. | Nested objects are still shared with the original. |
| A Date | Stays a Date. | Stays a Date. | Becomes a date string. | The same Date object is shared. |
| A RegExp | Keeps the pattern, the flags, and lastIndex. | Keeps the pattern and flags. lastIndex goes back to 0. | Becomes {}. | The same RegExp object is shared. |
| A Map or a Set | Copies every key and value. | Keeps the Map or Set data. | Becomes {}, unless you change it to an array yourself first. | The same Map or Set is shared. |
| A cycle | seen points the copy's link at the copy. | Works. | Throws a TypeError. | Only the top level is copied. |
| A function | The same function comes back. | Throws a DataCloneError. | Left out. | The same function is shared. |
| A class object | Keeps the prototype by default. Can give a plain object instead. | Copies the data into a plain object. | Only plain JSON data comes back. | Copies own data. The prototype's methods are not copied. |
Copy and compare data
STEP THROUGHTo deep clone a value means to copy it and every object inside it. The copy then shares nothing with the original. A first try usually handles arrays and plain objects. It has two problems:
- It never finishes when an object points back to itself. A loop like that is called a cycle.
- It turns Dates, RegExps, Maps, and Sets into plain objects, and it drops symbol keys. It also loses the object's prototype, the shared object where class methods like
greetlive.
So start with the rules. Each row below is one kind of input and what deepClone should do with it. Each row also becomes a test.
| Input | What deepClone does |
|---|---|
| A single value, like a number | Give back the same value. It can't be changed in place, so it needs no copy. |
| Arrays and plain objects | Make new containers, and copy every value inside them. |
| A Date | Make new Date(value.getTime()): a new object with the same time. |
| A RegExp | Copy the pattern, the flags, and lastIndex, which says where the next search starts. |
| A Map or a Set | Make an empty one, write it into seen, then copy each entry. |
| A cycle | Write "original → copy" into a WeakMap before copying the properties. |
| The prototype | Keep it by default. Give a plain object when the caller asks for one. |
function cloneJsonStyle(value) { if (value === null || typeof value !== "object") return value; if (Array.isArray(value)) return value.map(cloneJsonStyle); const copy = {}; for (const key of Object.keys(value)) { copy[key] = cloneJsonStyle(value[key]); } return copy;} const profile = { name: "Ada" };profile.self = profile;console.log(cloneJsonStyle(profile));- Line 6 calls
cloneJsonStyleagain for every property. A function that calls itself like this is called recursion. - Line 12 makes
profile.selfpoint back toprofile. That is the cycle. - Line 13 copies
profile. That means copyingprofile.self, which isprofileagain, and so on. The calls never end, so JavaScript stops them with aRangeError.
The fix is a WeakMap called seen. A WeakMap is a Map whose keys must be objects. Before the fixed clone copies an object's properties, it writes "this original → this copy" into seen. When the same original shows up again, it gives back the copy it already made.
deepClone writes "original → copy" in seen. When it reaches self, it finds that note and uses the copy instead of starting again.Step through the fixed deepClone below. The object has a class, a RegExp, a Map with a Set inside, and a cycle. Watch for the step where self finds the copy in seen.
Step through deepClone on an object with a class, a RegExp, a Map with a Set, and a cycle. Watch the step where self finds the copy in seen.
script
if (value === null || (typeof value !== "object" && typeof value !== "function")) return value; if (typeof value === "function") return value; if (seen.has(value)) return seen.get(value); if (value instanceof Date) { const copy = new Date(value.getTime()); seen.set(value, copy); return copy; } if (value instanceof RegExp) { const copy = new RegExp(value.source, value.flags); copy.lastIndex = value.lastIndex; seen.set(value, copy); return copy; } if (value instanceof Map) { const copy = new Map(); seen.set(value, copy); for (const [key, entry] of value) { copy.set(deepClone(key, options, seen), deepClone(entry, options, seen)); } return copy; } if (value instanceof Set) { const copy = new Set(); seen.set(value, copy); for (const entry of value) copy.add(deepClone(entry, options, seen)); return copy; } const copy = Array.isArray(value) ? new Array(value.length) : options.preservePrototype === false ? {} : Object.create(Object.getPrototypeOf(value)); seen.set(value, copy); for (const key of Reflect.ownKeys(value)) { if (Array.isArray(value) && key === "length") continue; Object.defineProperty(copy, key, { value: deepClone(value[key], options, seen), enumerable: true, configurable: true, writable: true, }); } return copy;} class Profile { constructor(name) { this.name = name; } greet() { return "Hi " + this.name; }}const original = new Profile("Ada");original.pattern = /clone/gi;original.pattern.lastIndex = 2;original.visits = new Map([[{ day: "Mon" }, new Set([1, 2])]]);original.self = original; const copy = deepClone(original);console.log(copy !== original);console.log(copy instanceof Profile);console.log(copy.pattern.lastIndex);console.log([...copy.visits.values()][0].has(2));console.log(copy.self === copy);Now compare the four ways of copying yourself. Pick a value and see what each way gives back.
const value = makeValue("date");console.log("deepClone:", summarize(deepClone(value)));try { console.log("structuredClone:", summarize(structuredClone(value))); }catch (error) { console.log("structuredClone:", error.name); }try { console.log("JSON:", summarize(JSON.parse(JSON.stringify(value)))); }catch (error) { console.log("JSON:", error.name); }console.log("spread:", summarize({ ...value }));Date:2024-01-02Date:2024-01-02{"when":"2024-01-02T00:00:00.000Z"}Date:2024-01-02You picked: Date. Compare what each way of copying keeps, changes, or refuses with an error.
Before you write your own clone, check structuredClone. It copies cycles, Dates, Maps, and Sets. Write your own clone only when you need different rules. For example:
- You want to keep a class's prototype, so its methods still work on the copy.
- You want functions kept as they are, instead of an error.
const profile = { name: "Asha", joined: new Date("2024-01-01T00:00:00.000Z") };
profile.self = profile;
const copy = structuredClone(profile);
console.log(copy !== profile);
console.log(Object.prototype.toString.call(copy.joined) === "[object Date]");
console.log(copy.self === copy);Line 1 makes a profile with a Date. Line 2 makes profile.self point back to profile. Line 3 copies everything. All three logs print true:
- Line 4: the copy is a different object from the original.
- Line 5:
copy.joinedis still a Date. - Line 6:
copy.selfpoints at the copy, not at the original.
Now the second helper. deepEqual(a, b) answers one question: do a and b hold the same data? Again, start with the rules. Each row below is one case and the rule this lesson picks for it.
| Case | The rule |
|---|---|
NaN | Equal to itself, because Object.is(NaN, NaN) is true. |
+0 and -0 | Different in this lesson. Use === instead if your app wants them equal. |
| Arrays | Both must be arrays with the same length and the same values in the same order. |
| Objects | The prototypes and the number of own keys must match. Then each value is compared. |
| Dates and RegExps | Compare the time or the pattern inside, not whether they are the same object. |
| Maps and Sets | The sizes must match, and each entry must have an equal partner on the other side. |
| Cycles | Remember each pair of objects being compared, so two equal cycles still finish. |
function deepEqual(a, b, seen = new WeakMap()) { if (Object.is(a, b)) return true; if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; const known = seen.get(a); if (known?.has(b)) return true; if (!known) seen.set(a, new WeakSet([b])); else known.add(b); if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && Object.is(a.getTime(), b.getTime()); if (a instanceof RegExp || b instanceof RegExp) return a instanceof RegExp && b instanceof RegExp && a.source === b.source && a.flags === b.flags && a.lastIndex === b.lastIndex; if (Array.isArray(a) || Array.isArray(b)) { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => deepEqual(item, b[index], seen)); } if (a instanceof Map || b instanceof Map) { if (!(a instanceof Map) || !(b instanceof Map) || a.size !== b.size) return false; const unmatched = [...b.entries()]; return [...a.entries()].every(([key, value]) => { const index = unmatched.findIndex(([otherKey, otherValue]) => deepEqual(key, otherKey, seen) && deepEqual(value, otherValue, seen)); if (index === -1) return false; unmatched.splice(index, 1); return true; }); } if (a instanceof Set || b instanceof Set) { if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false; const unmatched = [...b.values()]; return [...a.values()].every((value) => { const index = unmatched.findIndex((other) => deepEqual(value, other, seen)); if (index === -1) return false; unmatched.splice(index, 1); return true; }); } const aKeys = Reflect.ownKeys(a); const bKeys = Reflect.ownKeys(b); if (aKeys.length !== bKeys.length) return false; const bKeySet = new Set(bKeys); return aKeys.every((key) => bKeySet.has(key) && deepEqual(a[key], b[key], seen));} const left = { n: NaN, z: -0, list: [1, { x: 2 }] };const right = { list: [1, { x: 2 }], z: 0, n: NaN };console.log(deepEqual(left, right));right.z = -0;console.log(deepEqual(left, right));left.self = left;right.self = right;console.log(deepEqual(left, right));console.log(deepEqual(new Map([[{ id: 1 }, new Set([2])]]), new Map([[{ id: 1 }, new Set([2])]])));A simple first try starts with a === b. That already fails for NaN, because NaN === NaN is false. So line 2 uses Object.is(a, b) instead. Object.is works like === with two differences: NaN equals NaN, and +0 and -0 are different.
The rest of the function, in plain words:
- Lines 4 to 7 write each pair of objects being compared into
seen. If the same pair shows up again, the two values are going around the same cycle, so line 5 returnstrue. - Line 8 says both objects must have the same prototype.
- Lines 11 to 13 compare two arrays item by item, in order.
- Lines 14 to 33 match Map entries and Set values by their data, not by which object they are.
- Lines 34 to 38 compare plain objects: the same number of own keys, and equal values under each key.
The four logs print false, true, true, true. The first is false because left.z is -0 and right.z is 0. Line 44 sets right.z to -0, so line 45 prints true. Line 48 compares two objects that each point to themselves. Line 49 compares two Maps with equal data.
const hole = Array(1);
const missingValue = [undefined];
console.log(0 in hole);
console.log(0 in missingValue);
console.log(hole.flat().length);An array can have a hole: an index with nothing stored at it. Line 1 makes an array of length 1 with a hole at index 0. Line 2 makes an array that really stores undefined at index 0. The logs:
- Line 3 prints
false: index 0 doesn't exist inhole. - Line 4 prints
true: index 0 exists inmissingValue. - Line 5 prints
0:flat()skips holes, so nothing is left.
So a hole and undefined are different. Watch out: every on line 12 of deepEqual skips holes too, so that short version can't see the difference. The version this lesson's tests use also checks that each index exists on both sides.
Helpers that reshape functions
GROUPED ARGSCurrying turns a function that takes several arguments into one you can call a few arguments at a time. The currying lesson explains why that helps. Here you build the helper with three rules:
- It runs the real function once it has enough arguments. "Enough" is
fn.length, the number of parameters the function lists. This number is called the arity. - Each call can pass one argument or several.
- A special placeholder value,
__, keeps a slot open, so you can fill it later.
const __ = Symbol("placeholder");function curry(fn, arity = fn.length) { return function curried(...args) { const complete = args.length >= arity && args.slice(0, arity).every((arg) => arg !== __); if (complete) return fn.apply(this, args.slice(0, arity)); return function collect(...more) { const merged = args.map((arg) => arg === __ && more.length ? more.shift() : arg).concat(more); return curried.apply(this, merged); }; };} const format = (greeting, name, punctuation) => greeting + ", " + name + punctuation;const greet = curry(format);console.log(greet("Hi")("Ada")("!"));console.log(greet("Hi", "Lin")("."));console.log(greet(__, "Grace")("Hello")("!"));- Line 1 makes the placeholder. It is a
Symbol, a value that never equals any other value. - Line 2 sets
aritytofn.length, unless you pass a number yourself. - Line 4 checks two things: are there at least
arityarguments, and is none of the firstaritya placeholder? - If both are yes, line 5 calls the real function.
- If not, line 6 returns a new function that waits for more arguments.
- Line 7 puts the new arguments into the placeholder slots first. Any left over go at the end.
Line 17 shows the placeholder at work. greet(__, "Grace") leaves the greeting open. ("Hello") fills it. ("!") adds the last argument, so the line prints Hello, Grace!.
One catch: fn.length doesn't count a rest parameter like ...args, and it stops at the first parameter with a default value. For those functions, pass the arity yourself, like curry(fn, 3).
Composition joins small functions into one. The output of each function becomes the input of the next. Two helpers do this:
pipe(a, b, c)runsafirst, thenb, thenc. You list them in the order they run.compose(c, b, a)runs them in the same order, but you list them backwards, the way you would writec(b(a(x))).
const compose = (...fns) => (input) => fns.reduceRight((value, fn) => fn(value), input);const pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input);const pipeAsync = (...fns) => (input) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(input)); const trim = (text) => text.trim();const lower = (text) => text.toLowerCase();const dash = (text) => text.replaceAll(" ", "-"); console.log(compose(dash, lower, trim)(" Utility Lesson "));console.log(pipe(trim, lower, dash)(" Utility Lesson "));pipeAsync( async (id) => ({ id, role: "admin" }), async (user) => user.role.toUpperCase(),)(7).then(console.log);Each helper walks the list of functions and passes a value along:
compose, on lines 1 and 2, usesreduceRight. It walks the list from the end.pipe, on lines 3 and 4, usesreduce. It walks the list from the start.pipeAsync, on lines 5 and 6, is for functions that return Promises. It starts withPromise.resolve(input)and adds each function with.then(fn).
Lines 12 and 13 both print utility-lesson. Lines 14 to 17 print ADMIN after both async steps finish.
pipe lists the steps in the order they run. compose lists them backwards. Both run trim, then lower, then dash.Save results and run once
CACHE RULESTo memoize a function means to save its results in a cache, a store of saved answers. The next call with the same arguments gets the saved answer instead of doing the work again. Each saved answer sits under a key, a value that names that call.
A simple first try makes the key with JSON.stringify(args) and hides the cache inside. That causes problems:
{ a: 1, b: 2 }and{ b: 2, a: 1 }get different keys, even though they hold the same data.- Two different objects with the same data get the same key.
- Functions disappear from the key.
- Nobody can empty the cache, so it only grows.
The fixed memoize takes a second argument, a resolver. A resolver is a function you write that makes the key. The fixed version also shows its cache, so tests can look inside and empty it.
function memoize(fn, resolver = (...args) => JSON.stringify(args)) { const cache = new Map(); function memoized(...args) { const key = resolver(...args); if (cache.has(key)) return cache.get(key); const result = fn.apply(this, args); cache.set(key, result); return result; } memoized.cache = cache; memoized.clear = () => cache.clear(); return memoized;} function once(fn) { let called = false; let result; let error; let threw = false; return function runOnce(...args) { if (!called) { called = true; try { result = fn.apply(this, args); } catch (caught) { threw = true; error = caught; throw caught; } } if (threw) throw error; return result; };} let calls = 0;const area = memoize((w, h) => { calls++; return w * h; }, (w, h) => w + "x" + h);console.log(area(3, 4));console.log(area(3, 4));console.log(calls);area.clear();console.log(area(3, 4));console.log(calls); const button = { label: "save", init: once(function (suffix) { return this.label + suffix; }) };console.log(button.init("!"));console.log(button.init("?"));How memoize works, line by line:
- Line 2 makes the cache, a
Map. - Line 4 asks the resolver for the key.
- Line 5 returns the saved answer if the key is already in the cache.
- Otherwise, lines 6 to 8 run the function, save the result, and return it.
- Line 10 shows the cache, and line 11 adds a
clearfunction that empties it.
On line 32, the resolver (w, h) => w + "x" + h makes the key "3x4" for area(3, 4). Lines 33 and 34 both print 12, and line 35 prints 1: the function ran once. Line 36 empties the cache, so line 37 runs the function again, and line 38 prints 2.
area(3, 4) runs the function and saves 12 under the key "3x4". The second call finds that key, so the function does not run again and calls stays 1.once(fn) is simpler. It returns a new function, called a wrapper, that works like this:
- The first call runs
fnand saves the result. Line 23 usesfn.apply(this, args), sofngets the samethisand arguments as the wrapper. - Every later call gives back that first result, even with different arguments.
Lines 41 and 42 both print save!. The second call passes "?", but fn doesn't run again.
Two timer helpers follow the same habit. A timer runs code later, with setTimeout.
debouncewaits until the calls stop forwaitmilliseconds. Then it runs once, with the last arguments. A search box that waits until you stop typing works like this.throttleruns at most once in eachwaitwindow. If more calls come during the window, it runs once more at the end, with the last arguments.
Testing timers with real time is slow. So the example uses a fake clock: an object with its own setTimeout and a tick(ms) method that moves time forward at once.
function createFakeClock() { let now = 0; let nextId = 1; const tasks = []; return { now: () => now, setTimeout(callback, wait) { const task = { id: nextId++, time: now + wait, callback, cleared: false }; tasks.push(task); return task.id; }, clearTimeout(id) { for (const task of tasks) if (task.id === id) task.cleared = true; }, tick(ms) { const end = now + ms; while (true) { const ready = tasks.filter((task) => !task.cleared && task.time <= end).sort((a, b) => a.time - b.time)[0]; if (!ready) break; ready.cleared = true; now = ready.time; ready.callback(); } now = end; }, };} function debounce(fn, wait, clock = { setTimeout, clearTimeout }) { let timeout; return function debounced(...args) { clock.clearTimeout(timeout); timeout = clock.setTimeout(() => fn.apply(this, args), wait); };} function throttle(fn, wait, clock = { now: Date.now, setTimeout, clearTimeout }) { let lastRun = -Infinity; let timeout; let lastArgs; let lastThis; return function throttled(...args) { const remaining = wait - (clock.now() - lastRun); lastArgs = args; lastThis = this; if (remaining <= 0) { if (timeout !== undefined) clock.clearTimeout(timeout); timeout = undefined; lastRun = clock.now(); fn.apply(lastThis, lastArgs); } else if (timeout === undefined) { timeout = clock.setTimeout(() => { timeout = undefined; lastRun = clock.now(); fn.apply(lastThis, lastArgs); }, remaining); } };} const log = [];const clock = createFakeClock();const save = debounce((value) => log.push("debounced " + value), 100, clock);save("a");clock.tick(50);save("b");clock.tick(100);const paint = throttle((value) => log.push("throttled " + value), 100, clock);paint("one");clock.tick(20);paint("two");clock.tick(100);console.log(log.join(" | "));- Line 64 calls
save("a")at time 0. Line 65 moves the clock to 50. - Line 66 calls
save("b").debouncecancels the waiting"a"and waits 100 ms again. - Line 67 moves the clock to 150, so
"b"runs. - Line 69 calls
paint("one").throttlehasn't run yet, so"one"runs right away. - Line 71 calls
paint("two")only 20 ms later, sothrottlewaits. Line 72 moves the clock past the end of the window, so"two"runs. - Line 73 prints
debounced b | throttled one | throttled two.
function memoize(fn, resolver) {
const cache = new Map();
return (...args) => {
const key = resolver(...args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
let calls = 0;
const lookup = memoize(() => { calls += 1; return undefined; }, () => "profile");
lookup();
lookup();
console.log(calls);Here the resolver gives every call the same key, "profile", and the function returns undefined. Line 15 prints 1, so the function ran only once.
Why it works: cache.get(key) returns undefined in two cases. Either nothing is saved, or undefined is saved. cache.has(key) on line 5 tells the two cases apart. A resolver must also give different keys to inputs that should give different results. Otherwise one input gets another input's answer.
A few more rules you need to decide on:
- The resolver sees the arguments, not
this. If a memoized method depends on its object, put that object in the key, or keep one cache per object. - A cache that lives a long time keeps growing. Empty it with
clear, or limit its size. - If
fnthrows an error, thisoncesaves the error and throws it again on every later call. Decide whether a failed first run should be allowed to try again.
Flatten objects and read paths
ROUND TRIPTo flatten an object means to turn nested data into one level of keys. Each key is a path: the names you follow to reach a value, joined with dots. For example, { user: { name: "Ada" } } becomes { "user.name": "Ada" }. unflatten does the reverse.
The walking part is plain recursion. The hard part is the rules. You need answers to these questions:
- How are array items written?
- What happens to an empty object or an empty array?
- Can
unflattenbuild exactly the same shape again? - What if a real key has a dot in it?
Each row below is one case and this lesson's rule for it. Each row is also a test.
| Case | The rule |
|---|---|
| Nested objects | { a: { b: 1 } } becomes { 'a.b': 1 }. |
| Arrays | Each index becomes a part of the path: list.0, list.1, and so on. |
| An empty object | Keep {} at its path, so unflatten doesn't lose it. |
| An empty array | Keep [] at its path, for the same reason. |
| The way back | unflatten(flattenObject(value)) should build the same JSON data again. |
| Reading a path | get(obj, 'a.b[0].c', fallback) gives fallback only when the value is missing or undefined. |
flattenObject turns settings.user.name into one flat key, "user.name". unflatten splits the key at the dot and builds the user object again.Step through the code below. Watch flattenObject build each path, then watch unflatten build the object again.
Step through flattening nested settings into dot paths, then building the arrays and empty objects again on the way back.
script
const isObject = value !== null && typeof value === "object"; if (!isObject) { if (prefix) output[prefix] = value; return output; } const entries = Array.isArray(value) ? value.map((entry, index) => [String(index), entry]) : Object.entries(value); if (entries.length === 0 && prefix) { output[prefix] = Array.isArray(value) ? [] : {}; return output; } for (const [key, entry] of entries) { const path = prefix ? prefix + "." + key : key; flattenObject(entry, path, output); } return output;} function unflatten(flat) { const root = {}; for (const [path, value] of Object.entries(flat)) { const parts = path.split("."); let cursor = root; parts.forEach((part, index) => { const last = index === parts.length - 1; if (last) cursor[part] = value; else cursor = cursor[part] ??= /^\d+$/.test(parts[index + 1]) ? [] : {}; }); } return root;} const settings = { user: { name: "Ada" }, list: ["js", "utils"], empty: {} };const flat = flattenObject(settings);console.log(flat["user.name"]);console.log(flat["list.0"]);console.log(Array.isArray(flat.empty) ? "array" : typeof flat.empty);console.log(JSON.stringify(unflatten(flat)) === JSON.stringify(settings));get(obj, path, defaultValue) reads a value by its path. If a part of the path is missing, it gives back defaultValue instead of throwing an error.
function get(obj, path, defaultValue) { const parts = path.replace(/\[(\w+)\]/g, ".$1").split(".").filter(Boolean); let cursor = obj; for (const part of parts) { if (cursor == null) return defaultValue; cursor = cursor[part]; } return cursor === undefined ? defaultValue : cursor;} const data = { users: [{ profile: { name: "Ada" } }] };console.log(get(data, "users[0].profile.name", "Unknown"));console.log(get(data, "users[1].profile.name", "Unknown"));- Line 2 turns
[0]into.0, then splits the path at the dots. Sousers[0].profile.namebecomes the partsusers,0,profile, andname. - Lines 4 to 7 step into the object one part at a time.
- Line 5 returns the default if a part on the way is
nullorundefined. - Line 8 returns the default only if the final value is
undefined. Values like0,false, andnullare real values, so they come back as they are.
Line 12 prints Ada. Line 13 prints Unknown, because there is no users[1].
Path strings have one weak spot. The objects {"a.b": 1} and {"a": {"b": 1}} both have the path a.b. The path alone can't say which one you mean. There are two ways to fix this:
- Escape the dot: put a backslash before a dot that belongs to a key, like
a\.b. - Write the path as an array of parts instead of one string.
const profile = { "a.b": { name: "Asha" } };
const path = ["a.b", "name"];
const name = path.reduce((value, key) => value?.[key], profile);
console.log(name);- Line 1 has one key named
"a.b". - Line 2 writes the path as an array, so
"a.b"stays one part. - Line 3 uses
reduceto step through the parts.?.givesundefinedearly if a part is missing. - Line 4 prints
Asha.
An array path needs no escape rules. That helps when keys come from users.
The full path helpers this lesson tests go a bit further:
- They escape dots and brackets inside keys.
unflattentreats a key named__proto__as a normal key. A plain write likeobj["__proto__"] = valuewould change the object's prototype instead.- An array hole comes back as a real
undefined. If you must keep holes, use a different format.
Build it or use a ready one
SORT ITReal projects don't need you to rewrite these helpers from scratch. First decide who should handle the edge cases:
- Use a built-in, like
structuredCloneorObject.groupBy, when it follows exactly the rules you need. - Use a library when there are many edge cases. Lodash and Ramda are two popular libraries of helpers.
- Write your own helper when you need one narrow rule and can test it.
The table lists ready-made versions of this lesson's helpers. Read each row as: "here is where to find them, and when to pick them."
| Where they come from | Examples | When to pick them |
|---|---|---|
| Lodash | _.cloneDeep, _.isEqual, _.curry, _.memoize, _.once, _.debounce, _.throttle, _.get | Pick it when you need many tested edge cases and your project can add the library. |
| Ramda | R.curry, R.compose, R.pipe, R.memoizeWith, R.path | Pick it when your code already passes the data as the last argument everywhere, the way Ramda does. |
| Built-ins | structuredClone, Object.groupBy, Map, Set, WeakMap, Object.entries | Pick them first, when they follow exactly the rules you need. |
| Your own helper | A small function plus tests for each rule | Pick it when you need one narrow rule, not a whole library. |
const cart = [
{ name: "tea", type: "drink" },
{ name: "rice", type: "food" },
{ name: "milk", type: "drink" },
];
const grouped = Object.groupBy(cart, (item) => item.type);
console.log(grouped.drink.map((item) => item.name).join(", "));
console.log(Object.getPrototypeOf(grouped) === null);
console.log([["tea"], ["rice"]].flat().join(", "));- Line 6 uses
Object.groupBy. It puts the cart items into groups by theirtype. - Line 7 prints
tea, milk, the names in thedrinkgroup. - Line 8 prints
true: the result's prototype isnull. So it has no inherited methods liketoString. Use it only as a list of groups. - Line 9 uses the array method
flat(), which removes one level of nested arrays. It printstea, rice.
These built-ins replace many small helpers. But flat() works on arrays only. It can't turn an object into path keys the way flattenObject does.
- Copy plain data, Dates, Maps, Sets, and cycles. No functions, and the class doesn't matter.
- Compare many nested values across a big app that has met years of edge cases.
- Memoize one slow function that has a known string key and needs a way to empty its cache.
- Flatten keys from users that may contain dots, with no rule for escaping them.
- Make a setup function run only once and keep its first result.
- A team already passes the data as the last argument everywhere and wants pipelines in every file.
- Group array items by a property, in current Node.js and browsers.
- Deep copy live functions that remember outer variables, and page elements, as app data.
Sort each card by who should handle its edge cases: a built-in, a library, your own helper, or nobody, because the job is too unclear to write rules for.
Common misconceptions
- "Deep clone means copy everything." Some things can't be copied safely: functions that remember outer variables, page elements, private fields, and property settings like getters. A clone copies only what its contract lists.
- "A JSON round trip is a clone." It turns data into text and back. Dates come back as strings. Maps, Sets, functions,
undefined, and symbols are lost or changed. Cycles throw an error. - "Deep equal is obvious." Each of these needs a rule:
NaN,+0and-0, prototypes, the order of Map entries, Set values, and cycles. - "curry can always work out the arity."
fn.lengthdoesn't count a rest parameter, and it stops at the first parameter with a default value. Let callers pass the arity when needed. - "Path strings are always safe." A key with a dot or a bracket in it needs escaping, or an array path.
Each row below is a tempting shortcut. Read it as: "this shortcut fails when this happens, so do this instead."
| The shortcut | When it fails | What to do instead |
|---|---|---|
Copy with spread, { ...value } | The copy's nested objects must be separate from the original's. | Use spread only to copy one level. |
Compare with JSON.stringify(a) === JSON.stringify(b) | Keys are in a different order, or the data has NaN, Dates, Maps, or cycles. | Write the equality rules down and test them. |
Use JSON.stringify(args) as the cache key | Keys are in a different order, or it matters which object was passed. | Write a resolver that fits how the function is called. |
Split every path with path.split('.') | A key has a dot in it. | Escape dots in keys, or use an array path. |
Practice exercises
5 EXERCISESonceFill in once. Then type the three values the finished program prints.
function once(fn) {
// TODO: remember whether fn already ran and return the first result forever
}
let calls = 0;
const init = once(() => ++calls);
console.log(init());
console.log(init());
console.log(calls);function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
let calls = 0;
const init = once(() => ++calls);
console.log(init());
console.log(init());
console.log(calls);The first init() runs the callback. ++calls makes calls 1, and that 1 is saved. The second init() gives back the saved 1 without running the callback. So calls is still 1, and the program prints 1, 1, 1.
NaNChange the check so NaN counts as equal to itself. Then type what the fixed program prints.
function equal(a, b) {
if (a === b) return true;
return false;
}
console.log(equal(NaN, NaN));function equal(a, b) {
return Object.is(a, b);
}
console.log(equal(NaN, NaN));Object.is(NaN, NaN) is true, so the fixed program prints true. That matches this lesson's rule for NaN.
unflattenWrite the reverse of flattening for simple dot paths. Then type the JSON text it prints.
function unflatten(flat) {
const root = {};
// TODO: split each path and create containers
return root;
}
console.log(JSON.stringify(unflatten({ "user.name": "Ada", "list.0": "js" })));function unflatten(flat) {
const root = {};
for (const [path, value] of Object.entries(flat)) {
const parts = path.split(".");
let cursor = root;
parts.forEach((part, index) => {
const last = index === parts.length - 1;
if (last) cursor[part] = value;
else cursor = cursor[part] ??= /^\d+$/.test(parts[index + 1]) ? [] : {};
});
}
return root;
}
console.log(JSON.stringify(unflatten({ "user.name": "Ada", "list.0": "js" })));The solution walks each path one part at a time. It makes each missing object or array on the way, then writes the value at the last part.
get with a defaultMake get read the path users[0].profile.name, and give back the default when a user is missing. Then type the two values printed.
function get(obj, path, defaultValue) {
// TODO: support dots, brackets, and a default for missing values
}
const data = { users: [{ profile: { name: "Ada" } }] };
console.log(get(data, "users[0].profile.name", "Unknown"));
console.log(get(data, "users[1].profile.name", "Unknown"));function get(obj, path, defaultValue) {
const parts = path.replace(/\[(\w+)\]/g, ".$1").split(".").filter(Boolean);
let cursor = obj;
for (const part of parts) {
if (cursor == null) return defaultValue;
cursor = cursor[part];
}
return cursor === undefined ? defaultValue : cursor;
}
const data = { users: [{ profile: { name: "Ada" } }] };
console.log(get(data, "users[0].profile.name", "Unknown"));
console.log(get(data, "users[1].profile.name", "Unknown"));The path becomes the parts users, 0, profile, and name. The first call finds Ada. In the second call, users[1] is undefined, so the loop returns Unknown.
Assume the lesson's deepClone is available. Type the two values printed.
class Item { constructor(id) { this.id = id; } }
const copy = deepClone(new Item(7), { preservePrototype: false });
console.log(copy instanceof Item);
console.log(copy.id);With { preservePrototype: false }, the copy is a plain object, so copy instanceof Item is false. Its own id property is still copied, so copy.id is 7.
Check your understanding
7 QUESTIONSQuestion 1 of 7Why does the fixed
deepClonewrite into aWeakMapbefore it copies the properties?Choose an answer to see the explanation.
Question 2 of 7What does this
Object.iscode print?Read the code, then predictconsole.log(Object.is(NaN, NaN)); console.log(Object.is(+0, -0));Choose an answer to see the explanation.
Question 3 of 7What does this curried call print?
Read the code, then predictconst __ = Symbol("placeholder"); const add3 = (a, b, c) => a + b + c; const curried = curry(add3); console.log(curried(__, 2)(1)(3));Choose an answer to see the explanation.
Question 4 of 7What does the memoized area example print?
Read the code, then predictlet calls = 0; const area = memoize((w, h) => { calls++; return w * h; }, (w, h) => w + "x" + h); console.log(area(3, 4)); console.log(area(3, 4)); console.log(calls);Choose an answer to see the explanation.
Question 5 of 7What does this
onceexample print?Read the code, then predictconst tool = { label: "save", init: once(function (suffix) { return this.label + suffix; }) }; console.log(tool.init("!")); console.log(tool.init("?"));Choose an answer to see the explanation.
Question 6 of 7What do these logs print after flattening?
Read the code, then predictconst flat = flattenObject({ list: ["js"], empty: [] }); console.log(flat["list.0"]); console.log(Array.isArray(flat.empty));Choose an answer to see the explanation.
Question 7 of 7Why are path strings risky for keys that come from users?
Choose an answer to see the explanation.
Key takeaways
- A helper's name is not enough. Write down which inputs it accepts, which it doesn't, and what it does with each edge case.
deepCloneanddeepEqualonly work well when cycles, Dates, Maps, Sets, prototypes, and the equality rules are tested.curry,compose, andpipeshould make calls easier to read, not more clever.memoizeneeds a resolver for its keys and a way to empty its cache.oncemust keep the firstthis, arguments, and result.- Flattened paths need rules for arrays, empty objects and arrays, escaping, the reverse step, and default values.
Remember the one-liner.
Build a utility like a small library: rules first, a simple version that fails second, the fixed version third, and tests the whole time.
Next in this module, build an event emitter turns callbacks into a small system that tells listeners when something happens. The same habit applies: write the rules, build the smallest working version, and test the edge cases.