completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Classic utilities

Build production-grade deepClone, deepEqual, curry, compose, memoize, once, flattenObject, unflatten, and get utilities with edge-case tests.

By the end, you can
  • 01
    Write 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.
  • 02
    Handle the unusual inputs on purposeDecide what happens to Dates, RegExps, Maps, Sets, cycles, placeholders, saved results, empty objects, and missing paths.
  • 03
    Choose 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:

  • deepClone copies a value and every object inside it.
  • deepEqual checks whether two values hold the same data.
  • curry lets you pass a function's arguments a few at a time.
  • compose and pipe join small functions into one.
  • memoize saves a result, so the same call doesn't do the work twice.
  • once lets a function run only one time.
  • flattenObject, unflatten, and get turn 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:

  1. Write its rules: which inputs it accepts, and what it gives back for each one.
  2. Build a simple version and watch it fail on an edge case.
  3. Fix it.
  4. Prove each rule with a test that runs real JavaScript.
The main idea

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.

Real-life analogyA toolbox with safety cards

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:

  • deepClone is the helper you build in this lesson.
  • structuredClone is 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 each way of copying keeps
What you copydeepClonestructuredCloneJSON round tripSpread
Nested plain dataA 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 DateStays a Date.Stays a Date.Becomes a date string.The same Date object is shared.
A RegExpKeeps 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 SetCopies 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 cycleseen points the copy's link at the copy.Works.Throws a TypeError.Only the top level is copied.
A functionThe same function comes back.Throws a DataCloneError.Left out.The same function is shared.
A class objectKeeps 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 THROUGH

To 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 greet live.

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.

The rules for deepClone
InputWhat deepClone does
A single value, like a numberGive back the same value. It can't be changed in place, so it needs no copy.
Arrays and plain objectsMake new containers, and copy every value inside them.
A DateMake new Date(value.getTime()): a new object with the same time.
A RegExpCopy the pattern, the flags, and lastIndex, which says where the next search starts.
A Map or a SetMake an empty one, write it into seen, then copy each entry.
A cycleWrite "original → copy" into a WeakMap before copying the properties.
The prototypeKeep it by default. Give a plain object when the caller asks for one.
A simple clone that never finishes on a cyclePop out in the code editor (opens in a new tab)JavaScript
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));
  1. Line 6 calls cloneJsonStyle again for every property. A function that calls itself like this is called recursion.
  2. Line 12 makes profile.self point back to profile. That is the cycle.
  3. Line 13 copies profile. That means copying profile.self, which is profile again, and so on. The calls never end, so JavaScript stops them with a RangeError.

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 copies the original cart into a copy cart. Both carts hold name Ada and self. In the original, self points to original. In the copy, self points to copy. A card at the bottom says seen: original to copy.name: "Ada"selforiginaldeepClonename: "Ada"selfcopyself → originalself → copyseen: original → copy
Before it copies any property, 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.

Copy an object that points to itself
Step 0 of 16Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
  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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Now compare the four ways of copying yourself. Pick a value and see what each way gives back.

Copy one value four ways
Copy the value four waysJavaScript
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 }));
What each way gives backDate
deepCloneDate:2024-01-02
structuredCloneDate:2024-01-02
JSON round-trip{"when":"2024-01-02T00:00:00.000Z"}
spreadDate:2024-01-02
Try it yourself
Choose a value to copy

You picked: Date. Compare what each way of copying keeps, changes, or refuses with an error.

The code shows the four ways of copying. The result cards come from running the lesson's real helper functions.

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.
structuredClone keeps the date and the cyclePop out in the code editor (opens in a new tab)JavaScript
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.joined is still a Date.
  • Line 6: copy.self points 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.

The rules for deepEqual
CaseThe rule
NaNEqual to itself, because Object.is(NaN, NaN) is true.
+0 and -0Different in this lesson. Use === instead if your app wants them equal.
ArraysBoth must be arrays with the same length and the same values in the same order.
ObjectsThe prototypes and the number of own keys must match. Then each value is compared.
Dates and RegExpsCompare the time or the pattern inside, not whether they are the same object.
Maps and SetsThe sizes must match, and each entry must have an equal partner on the other side.
CyclesRemember each pair of objects being compared, so two equal cycles still finish.
deepEqual that works with cyclesPop out in the code editor (opens in a new tab)JavaScript
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:

  1. 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 returns true.
  2. Line 8 says both objects must have the same prototype.
  3. Lines 11 to 13 compare two arrays item by item, in order.
  4. Lines 14 to 33 match Map entries and Set values by their data, not by which object they are.
  5. 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.

An array hole is not the same as undefinedPop out in the code editor (opens in a new tab)JavaScript
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 in hole.
  • Line 4 prints true: index 0 exists in missingValue.
  • 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 ARGS

Currying 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.
curry with grouped arguments and placeholdersPop out in the code editor (opens in a new tab)JavaScript
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")("!"));
  1. Line 1 makes the placeholder. It is a Symbol, a value that never equals any other value.
  2. Line 2 sets arity to fn.length, unless you pass a number yourself.
  3. Line 4 checks two things: are there at least arity arguments, and is none of the first arity a placeholder?
  4. If both are yes, line 5 calls the real function.
  5. If not, line 6 returns a new function that waits for more arguments.
  6. 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) runs a first, then b, then c. 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 write c(b(a(x))).
compose, pipe, and an async pipePop out in the code editor (opens in a new tab)JavaScript
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, uses reduceRight. It walks the list from the end.
  • pipe, on lines 3 and 4, uses reduce. It walks the list from the start.
  • pipeAsync, on lines 5 and 6, is for functions that return Promises. It starts with Promise.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.

Three function cards in a row: trim, then lower, then dash, then the result utility-lesson. Above is pipe(trim, lower, dash). Below is compose(dash, lower, trim). Both run the same row.pipe(trim, lower, dash)trimlowerdashutility-lessoncompose(dash, lower, trim)
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 RULES

To 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.

memoize with a resolver, and once with its first resultPop out in the code editor (opens in a new tab)JavaScript
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:

  1. Line 2 makes the cache, a Map.
  2. Line 4 asks the resolver for the key.
  3. Line 5 returns the saved answer if the key is already in the cache.
  4. Otherwise, lines 6 to 8 run the function, save the result, and return it.
  5. Line 10 shows the cache, and line 11 adds a clear function 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.

Left: the first call area(3, 4) puts the card 3x4: 12 into a box called cache. Right: the second call area(3, 4) gets 12 from the cache.area(3, 4)"3x4": 12cachearea(3, 4)1st call runsand saves 122nd call reads12 from cache
The first 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 fn and saves the result. Line 23 uses fn.apply(this, args), so fn gets the same this and 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.

  • debounce waits until the calls stop for wait milliseconds. Then it runs once, with the last arguments. A search box that waits until you stop typing works like this.
  • throttle runs at most once in each wait window. 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.

debounce and throttle with a fake clockPop out in the code editor (opens in a new tab)JavaScript
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(" | "));
  1. Line 64 calls save("a") at time 0. Line 65 moves the clock to 50.
  2. Line 66 calls save("b"). debounce cancels the waiting "a" and waits 100 ms again.
  3. Line 67 moves the clock to 150, so "b" runs.
  4. Line 69 calls paint("one"). throttle hasn't run yet, so "one" runs right away.
  5. Line 71 calls paint("two") only 20 ms later, so throttle waits. Line 72 moves the clock past the end of the window, so "two" runs.
  6. Line 73 prints debounced b | throttled one | throttled two.
Save a result even when it is undefinedPop out in the code editor (opens in a new tab)JavaScript
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 fn throws an error, this once saves 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 TRIP

To 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 unflatten build 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.

The rules for flattening
CaseThe rule
Nested objects{ a: { b: 1 } } becomes { 'a.b': 1 }.
ArraysEach index becomes a part of the path: list.0, list.1, and so on.
An empty objectKeep {} at its path, so unflatten doesn't lose it.
An empty arrayKeep [] at its path, for the same reason.
The way backunflatten(flattenObject(value)) should build the same JSON data again.
Reading a pathget(obj, 'a.b[0].c', fallback) gives fallback only when the value is missing or undefined.
Left: a cart called user holds name Ada. An arrow labeled flattenObject goes right to one card that says user.name: Ada. An arrow labeled unflatten goes back to the cart.name: "Ada"userflattenObjectuser.name: "Ada"unflatten
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.

Flatten settings into paths, then build them again
Step 0 of 11Ready
Your turn: follow the blue line

Step through flattening nested settings into dot paths, then building the arrays and empty objects again on the way back.

Running in
  1. script
Next: line 1
  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));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

get with dot and bracket pathsPop out in the code editor (opens in a new tab)JavaScript
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"));
  1. Line 2 turns [0] into .0, then splits the path at the dots. So users[0].profile.name becomes the parts users, 0, profile, and name.
  2. Lines 4 to 7 step into the object one part at a time.
  3. Line 5 returns the default if a part on the way is null or undefined.
  4. Line 8 returns the default only if the final value is undefined. Values like 0, false, and null are 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.
Use an array path when a key has a dotPop out in the code editor (opens in a new tab)JavaScript
const profile = { "a.b": { name: "Asha" } };
const path = ["a.b", "name"];
const name = path.reduce((value, key) => value?.[key], profile);
console.log(name);
  1. Line 1 has one key named "a.b".
  2. Line 2 writes the path as an array, so "a.b" stays one part.
  3. Line 3 uses reduce to step through the parts. ?. gives undefined early if a part is missing.
  4. 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.
  • unflatten treats a key named __proto__ as a normal key. A plain write like obj["__proto__"] = value would 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 IT

Real projects don't need you to rewrite these helpers from scratch. First decide who should handle the edge cases:

  • Use a built-in, like structuredClone or Object.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."

Ready-made versions of these helpers
Where they come fromExamplesWhen to pick them
Lodash_.cloneDeep, _.isEqual, _.curry, _.memoize, _.once, _.debounce, _.throttle, _.getPick it when you need many tested edge cases and your project can add the library.
RamdaR.curry, R.compose, R.pipe, R.memoizeWith, R.pathPick it when your code already passes the data as the last argument everywhere, the way Ramda does.
Built-insstructuredClone, Object.groupBy, Map, Set, WeakMap, Object.entriesPick them first, when they follow exactly the rules you need.
Your own helperA small function plus tests for each rulePick it when you need one narrow rule, not a whole library.
Group items and flatten one array levelPop out in the code editor (opens in a new tab)JavaScript
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(", "));
  1. Line 6 uses Object.groupBy. It puts the cart items into groups by their type.
  2. Line 7 prints tea, milk, the names in the drink group.
  3. Line 8 prints true: the result's prototype is null. So it has no inherited methods like toString. Use it only as a list of groups.
  4. Line 9 uses the array method flat(), which removes one level of nested arrays. It prints tea, 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.

Who should handle the edge cases?
  • 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.
Try it yourself
0 of 8 correct

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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, +0 and -0, prototypes, the order of Map entries, Set values, and cycles.
  • "curry can always work out the arity." fn.length doesn'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."

Shortcuts and when they fail
The shortcutWhen it failsWhat 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 keyKeys 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 EXERCISES
Exercise 1 · Warm-upWrite once

Fill in once. Then type the three values the finished program prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFix an equality check that fails on NaN

    Change the check so NaN counts as equal to itself. Then type what the fixed program prints.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function equal(a, b) {
      if (a === b) return true;
      return false;
    }
    console.log(equal(NaN, NaN));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite unflatten

      Write the reverse of flattening for simple dot paths. Then type the JSON text it prints.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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" })));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWrite get with a default

        Make get read the path users[0].profile.name, and give back the default when a user is missing. Then type the two values printed.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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"));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChallenge: choose the prototype rule

          Assume the lesson's deepClone is available. Type the two values printed.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          class Item { constructor(id) { this.id = id; } }
          const copy = deepClone(new Item(7), { preservePrototype: false });
          console.log(copy instanceof Item);
          console.log(copy.id);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Why does the fixed deepClone write into a WeakMap before it copies the properties?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this Object.is code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              console.log(Object.is(NaN, NaN));
              console.log(Object.is(+0, -0));

              Choose an answer to see the explanation.

            3. Question 3 of 7What does this curried call print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const __ = 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.

            4. Question 4 of 7What does the memoized area example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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);

              Choose an answer to see the explanation.

            5. Question 5 of 7What does this once example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            6. Question 6 of 7What do these logs print after flattening?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const flat = flattenObject({ list: ["js"], empty: [] });
              console.log(flat["list.0"]);
              console.log(Array.isArray(flat.empty));

              Choose an answer to see the explanation.

            7. 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.
            • deepClone and deepEqual only work well when cycles, Dates, Maps, Sets, prototypes, and the equality rules are tested.
            • curry, compose, and pipe should make calls easier to read, not more clever.
            • memoize needs a resolver for its keys and a way to empty its cache. once must keep the first this, 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.

            CompleteFrontend Clear concepts. Working examples.