Advanced recursion
Master tail calls, trampolines, CPS, deep clone, and deep equal while keeping recursive JavaScript safe for real nested data.
- 01Spot a tail callTell when a call is the last thing a function does, and explain why Chrome and Node still need another plan for deep recursion.
- 02Handle very deep recursionUse a trampoline or your own array stack when the input can be tens of thousands of steps deep.
- 03Copy and compare nested data safelyDeep-clone and deep-compare nested objects, arrays, Dates, and Maps, even when the data points back to itself.
Recursion after the basics
The Recursion lesson covered the basics. A recursive function is a function that calls itself. It has two parts:
- The base case stops the calls.
- The recursive case calls the function again with a smaller input.
Each call that is still waiting for an answer keeps a stack frame. A stack frame is a small record of that call's variables, kept on the call stack. The call stack has a size limit. If too many calls wait at once, JavaScript throws RangeError: Maximum call stack size exceeded. This is called a stack overflow.
This lesson asks two new questions:
- What if the input is 100,000 levels deep?
- What if the data points back to itself?
Advanced recursion means choosing where the waiting work is kept. You will learn these ways:
- Keep the waiting work in an argument, so nothing waits after the call. This is a tail call.
- Return a small function for the next step, and let a loop run it. This is a trampoline.
- Keep the waiting work in your own array, and use a loop. This is an explicit stack.
- Pass a callback that says what to do with the answer. This is continuation-passing style.
- Remember the objects you have already visited, so data that points back to itself doesn't trap you.
- A tail call is a call that is the very last thing a function does. The function returns the call's result as it is.
- A trampoline is a loop that keeps calling small functions until it gets back something that is not a function.
- Continuation-passing style passes a callback that says what to do next with the answer.
- A deep clone copies an object and everything inside it. A deep equal check compares two values and everything inside them. Both use a
WeakMapto remember objects they have already seen.
You are looking through folders inside folders on your computer. Each time you open a folder, you could say: "Open the next one, then come back here." Then you must remember every folder you still need to come back to.
Instead, you can write the folders you still need to open on a to-do list. You open one folder at a time and cross it off. If you put a sticker on each folder you open, you never open the same folder twice.
- In real life: Open a folder inside another folder
- In JavaScript: A recursive call works on a smaller part of the problem
- In real life: Write the folders still to open on a to-do list
- In JavaScript: Keep waiting work in an array, and loop over it
- In real life: Put a sticker on each folder you open
- In JavaScript: A
WeakMapremembers objects already copied or compared - In real life: Never go back to a folder once you open the next one
- In JavaScript: A tail call: nothing is left to do after the call
These ideas build on earlier lessons: stack and heap, closures, WeakMap and WeakSet, and structuredClone from messaging and cloning.
Tail calls: nothing waits after the call
STEP THROUGHA call is in tail position when it is the very last thing a function does. The function returns the call's result as it is, with no more work. A call in tail position is called a tail call.
Compare the two factorial functions below. The factorial of 5 is 5 × 4 × 3 × 2 × 1, which is 120.
function factorial(n) { if (n <= 1) return 1; return n * factorial(n - 1);} function factorialTail(n, acc = 1) { if (n <= 1) return acc; return factorialTail(n - 1, n * acc);} console.log(factorial(5));console.log(factorialTail(5));Line 3 is not a tail call. After factorial(n - 1) gives back its answer, line 3 still has to multiply it by n. So each call waits, and keeps its stack frame.
Line 8 is a tail call. factorialTail has a second parameter, acc. It is short for accumulator: a value that collects the answer as the calls go on. Line 8 multiplies first, n * acc, and passes the product to the next call. After that call, there is nothing left to do.
Lines 11 and 12 print 120 twice. Both functions give the same answer. Only the waiting work is different.
The table below explains four terms that sound alike. Read each row as: the term, what it means, and an example.
| Term | What it means | Example |
|---|---|---|
| Not a tail call | More work happens after the call gives back its answer. | return n * factorial(n - 1); still has to multiply. |
| Tail call | The call is the last thing the function does. | return factorialTail(n - 1, n * acc); has nothing left to do. |
| Proper tail call | A rule in the standard: the engine reuses the stack frame for a tail call in strict mode. | ES2015 has the rule. Of the big engines, only Safari's follows it. |
| Trampoline | A loop in your own code that keeps calling the thunks it gets back. | It works the same in every engine. |
Step through factorialTail(5) below. Watch acc go from 1 to 5, then 20, then 60, then 120. The multiplication that used to wait now travels inside acc.
acc before it makes the next call. When n reaches 1, acc already holds 120, so nothing is left to do on the way back.Step through a factorial with a tail call. Watch acc carry the multiplication, so nothing is left to do after the call on line 3.
script
function factorialTail(n, acc = 1) { if (n <= 1) return acc; return factorialTail(n - 1, n * acc);} A JavaScript engine is the program inside a browser or Node that runs your code. The ES2015 standard says an engine should reuse the stack frame for a tail call in strict mode. This is called a proper tail call. But the engines don't all do it:
- JavaScriptCore, the engine in Safari, does it.
- V8, the engine in Chrome and Node, does not.
- SpiderMonkey, the engine in Firefox, does not.
So write tail calls because they are easy to follow. Don't count on them to stop a stack overflow.
The next example throws an error in Chrome and Node on purpose. It uses strict mode and a tail call, but V8 still adds a stack frame for every call. The exact limit is different on each computer, so the example uses a large number.
"use strict";function dive(n) { if (n === 0) return "done"; return dive(n - 1);} console.log(dive(100000));Line 1 turns on strict mode. Line 4 calls dive again with a number one smaller. Line 7 starts at 100,000. In V8, the stack fills up long before n reaches 0. So it throws a RangeError instead of printing done.
Trampolines: return the next step instead of taking it
INTERACTIVEA thunk is a function with no parameters that holds one step of work for later. For example, () => sumThunk(4, 5) is a thunk. Nothing happens until you call it.
A trampoline is a loop that keeps calling thunks until it gets back something that is not a function. In the code below, sumThunk adds the numbers from n down to 1. It doesn't call itself. It returns a thunk for the next step, and the loop calls that thunk. So the call for 4 never runs inside the call for 5. Each step starts fresh from the loop.
A trampoline gets a thunk back from each step, instead of a deeper call. Step through the loop: each turn calls one thunk, and then the call stack is short again.
script
function trampoline(task) { let current = task; let steps = 0; while (typeof current === "function") { current = current(); steps = steps + 1; } return { value: current, steps };} function sumThunk(n, acc = 0) { if (n === 0) return acc; return () => sumThunk(n - 1, acc + n);} Read the loop line by line:
- Line 2:
currentstarts as the first thunk. - Line 3:
stepscounts the turns of the loop. - Line 4: the loop goes on while
currentis still a function. - Line 5: call exactly one thunk, and keep what it gives back in
current. - Line 6: add one to the turn count.
- Line 8: once
currentis a number, return it with the count.
Line 13 is where sumThunk returns a thunk instead of calling itself. Line 16 prints 15, which is 5 + 4 + 3 + 2 + 1.
current and keeps what it gives back. Turns 1 to 5 give back a new thunk; turn 6 gives back the number 15, so the loop stops.Now try a much bigger number. Pick a size below. The loop still calls one thunk per turn, so the call stack never grows.
function trampoline(task) { let current = task; let steps = 0; while (typeof current === "function") { current = current(); steps = steps + 1; } return { value: current, steps };} function sumThunk(n, acc = 0) { if (n === 0) return acc; return () => sumThunk(n - 1, acc + n);} const result = trampoline(() => sumThunk(100000));console.log(result.value);console.log(result.steps > 100000);5,000,050,000trueThe loop ran 100,001 turns and returned 5,000,050,000. It never needed 100,000 stack frames at once.
A trampoline makes a new small function for every step. That costs time and memory. It also makes errors harder to read. The stack trace, the list of calls shown with an error, no longer shows the steps.
Use a trampoline when you want to keep the recursive shape of your code and the depth can be large. To walk a tree, an explicit stack (next section) is usually simpler and faster.
Turn recursion into a loop with your own stack
COMPAREOften the safest rewrite is the simplest one. Keep the waiting work in an ordinary array, and loop until the array is empty. This array is called an explicit stack, because you can see it in your code.
The array lives in the heap, the big part of memory where objects and arrays are kept. The heap has far more room than the call stack. And the call stack stays one frame deep, because no function calls itself. This works well for a folder of files, a walk through a page's elements, or comment threads where users can nest replies as deep as they like.
First, here is a recursive version that counts the comments in a thread:
const comments = {
text: "Welcome",
replies: [
{ text: "Thanks", replies: [{ text: "You are welcome", replies: [] }] },
{ text: "Helpful", replies: [] },
],
};
function countComments(comment) {
return 1 + comment.replies.reduce((total, reply) => total + countComments(reply), 0);
}
console.log(countComments(comments));Lines 1 to 7 make a comment with two replies. The first reply has a reply of its own. Line 10 counts the current comment as 1. Then reduce goes through each reply and adds that reply's own count. So the same small function works at every level. Line 13 prints 4, for the four comments.
If users write the replies, the thread can be deeper than the call stack allows. The panel below walks a tree of files with a loop and an array instead.
const tree = { name: "site", children: [ { name: "index.html" }, { name: "assets", children: [ { name: "logo.svg" }, { name: "app.js" }, ] }, { name: "notes.txt" }, ],}; function countFilesIterative(root) { const stack = [root]; let files = 0; while (stack.length > 0) { const node = stack.pop(); if (!node.children) { files = files + 1; continue; } for (const child of node.children) { stack.push(child); } } return files;} console.log(countFilesIterative(tree));4
The array named stack is ordinary data in the heap. The loop pushes children onto it and pops one item at a time, so the call stack stays one frame deep.
Read the loop line by line:
- Line 14: the array
stackstarts with the whole tree. - Line 16: the loop runs while the array still has work in it.
- Line 17:
pop()takes the last item off the end of the array. - Lines 18 to 20: an item with no
childrenis a file, so add one tofiles. - Lines 22 and 23: an item with children is a folder, so push each child onto the array for later.
- Line 29: print
4, for index.html, logo.svg, app.js, and notes.txt.
site, the array holds its three children. pop() takes the last one pushed, notes.txt. It is a file, so files becomes 1.`return factorialTail(n - 1, n * acc);``return () => sumThunk(n - 1, acc + n);``const stack = [root]; while (stack.length) ...``sumCps(n - 1, (small) => done(small + n))`- Walk 80,000 nested page elements while importing content
- Build a parser where each step returns the next step as a function
Put each card under the tool it uses. Each answer explains why.
Continuation-passing style: pass what to do next
CONCEPTA continuation is a callback that says what to do with an answer. In continuation-passing style (CPS for short), a function doesn't return its answer in the usual way. It receives a callback, often named done, and calls done(answer) instead.
function sumCps(n, done) { if (n === 0) return done(0); return sumCps(n - 1, (smaller) => { return done(smaller + n); });} sumCps(4, (answer) => console.log(answer));In plain words, line by line:
- Line 1:
sumCpstakes a numbernand a callbackdone. - Line 2: the base case. When
nis 0, calldone(0). - Lines 3 and 4: call
sumCpswithn - 1and a new callback. When the smaller sum arrives assmaller, the new callback addsnand passes the total to the olderdone. - Line 8: start with 4 and a callback that prints the answer. It prints
10, which is 1 + 2 + 3 + 4.
This is the same idea as callbacks and, later, async code: you hand another piece of code the next thing to do.
This example still calls sumCps from inside sumCps. So a very large n can still overflow the stack in V8. CPS is safe for deep work only when a trampoline runs the steps, or when each callback runs later, for example after a timer.
Functions that call each other, and saving answers
DEEPENMutual recursion means two or more functions call each other in a circle. Each function has its own base case. Otherwise, it passes a smaller input to the other function.
function isEven(n) {
if (n === 0) return true;
return isOdd(n - 1);
}
function isOdd(n) {
if (n === 0) return false;
return isEven(n - 1);
}
console.log(isEven(4), isOdd(5));isEven(4) calls isOdd(3). That calls isEven(2), then isOdd(1), then isEven(0), which returns true. Line 11 prints true true: 4 is even, and 5 is odd. Each call still adds a stack frame in V8, so a very large number can overflow. Use a loop when you don't control how big the number gets.
Memoization means saving a function's answer for an input, so a later call with the same input can reuse it. The saved answers are called a cache. It helps when a recursive function asks for the same smaller problem many times.
The Fibonacci numbers are a good example. Each one is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, 13, 21. To work out fib(8), you need fib(7) and fib(6). But fib(7) needs fib(6) too. Without a cache, the same numbers get worked out again and again.
const cache = new Map();
let calls = 0;
function fib(n) {
calls = calls + 1;
if (cache.has(n)) return cache.get(n);
if (n < 2) return n;
const answer = fib(n - 1) + fib(n - 2);
cache.set(n, answer);
return answer;
}
console.log(fib(8));
console.log(calls);In plain words, line by line:
- Line 1:
cacheis aMapfrom an input to its answer. - Line 2:
callscounts how many timesfibruns. - Line 6: if the answer for
nis already saved, return it. - Line 7: the base case.
fib(0)is 0, andfib(1)is 1. - Lines 8 and 9: work out the answer, then save it in the cache.
- Line 13 prints
21, and line 14 prints15.
Fifteen calls is far fewer than the 67 calls the same function makes without a cache. A cache key must describe the whole input. If the input can change, you also need a rule for when to throw saved answers away.
A cache is not the same as the seen map later in this lesson. A cache reuses an answer. A seen map only remembers which objects you already visited, so you don't go round the same loop in the data forever.
Don't cache a function whose answer depends on the current time, random numbers, or values that change elsewhere in the program, unless the key includes them. Otherwise the cache can hand back an old, wrong answer that looks correct.
Recursive data: deep clone with a WeakMap
INTERACTIVERecursive data is data that holds smaller pieces with the same shape. For example:
- An object can hold an array of objects.
- A
Mapcan hold objects as its values. - A comment can hold replies, which hold more replies.
A deep clone copies an object and everything inside it. The hard part is a cycle: data that points back to itself. After profile.self = profile, the self property points to the same object it lives in. A clone that just follows every property would go round that loop forever.
The fix is a WeakMap. A WeakMap is like a Map whose keys are objects. It doesn't stop those objects from being cleared out of memory when nothing else uses them. Here it is called seen, and it links each original object to its copy. A safe deep clone works in this order:
- If the value is a primitive, such as a number or a string, return it as it is.
- If
seenalready has this object, return the copy saved there. - Make a new, empty copy.
- Save the pair "original, copy" in
seen. - Only then, copy the children.
Step 4 must come before step 5. Then, when a child points back to the original, the clone finds it in seen and uses the copy.
deepClone saves the pair profile → copy in seen. When it reaches self, it finds profile in seen and uses the copy.The panel below runs the full helper. Use the buttons to compare it with structuredClone, the clone function built into browsers and Node.
function deepClone(value, seen = new WeakMap()) { if (Object(value) !== value || typeof value === "function") return value; if (seen.has(value)) return seen.get(value); if (value instanceof Date) return new Date(value.getTime()); if (value instanceof Map) { const clone = new Map(); seen.set(value, clone); for (const [key, entry] of value) { clone.set(deepClone(key, seen), deepClone(entry, seen)); } return clone; } if (Array.isArray(value)) { const clone = []; seen.set(value, clone); for (const item of value) clone.push(deepClone(item, seen)); return clone; } const clone = Object.create(Object.getPrototypeOf(value)); seen.set(value, clone); for (const key of Reflect.ownKeys(value)) { clone[key] = deepClone(value[key], seen); } return clone;} const profile = { name: "Ada", dates: [new Date("2020-01-02T00:00:00Z")], meta: new Map([["visits", 3]]),};profile.self = profile; const copy = deepClone(profile);console.log(copy !== profile);console.log(copy.dates[0] instanceof Date);console.log(copy.meta.get("visits"));console.log(copy.self === copy);truetrue3true
The custom clone keeps Dates, Maps, arrays, and the loop in the data. It remembers each original object and its copy in a WeakMap.
In the custom clone, line by line:
- Line 2: a primitive, or a function, comes back as it is.
- Line 3: an object already in
seengives back its saved copy. - Line 4: a
Dategets a newDatewith the same time. - Lines 7, 15, and 20: a
Map, an array, or a plain object saves its new copy inseenbefore its children are copied. - Line 32:
profile.self = profilemakes the cycle. - Lines 35 to 38 print
true,true,3, andtrue. The last one shows the copy'sselfpoints to the copy.
The table below compares the two ways to clone. Read each row as: the kind of value, what the custom clone does, and what structuredClone does.
| Kind of value | What the custom clone does | What structuredClone does |
|---|---|---|
| Nested arrays and objects | Copies each level, and uses a seen map for loops in the data. | Copies them too, loops included. |
| Date | Makes a new Date with the same time: new Date(value.getTime()). | Copies it as a Date too. |
| Map | Saves the new Map in seen, then copies each key and value. | Copies it, and keeps the order of its entries. |
| Functions | Keeps the same function, because that is this helper's rule. | Throws a DataCloneError. |
| Page elements (DOM nodes) | Not meant for these. Use node.cloneNode(true) instead. | Can't copy them. It throws a DataCloneError. |
| Class objects | Keeps the class, so its methods still work. | Gives back a plain object, without the class methods. |
Use structuredClone for plain data that it supports: arrays, objects, Dates, Maps, Sets, ArrayBuffers, and cycles. Write your own helper only when you need a special rule, for example for functions or class methods. Elements on a page are not plain data. Copy them with node.cloneNode(true) instead.
Deep equal: you choose the rules
INTERACTIVEA deep equal helper walks through two values side by side. It checks that everything inside them matches. Before you write one, you must decide some rules. A set of rules like this is called a policy. You must answer questions like these:
- Should
NaN("not a number") count as equal toNaN? - Should
-0count as equal to0? - Does the order of an object's keys matter?
- Does the order of an array's items matter?
- What happens when both values point back to themselves?
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) return Object.is(a.getTime(), b.getTime()); if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false; return a.every((item, index) => deepEqual(item, b[index], seen)); } if (a instanceof Map) { if (!(b instanceof Map) || a.size !== b.size) return false; const aEntries = [...a.entries()]; const bEntries = [...b.entries()]; return aEntries.every(([key, value], index) => { return deepEqual(key, bEntries[index][0], seen) && deepEqual(value, bEntries[index][1], seen); }); } const aKeys = Reflect.ownKeys(a); const bKeys = Reflect.ownKeys(b); if (aKeys.length !== bKeys.length) return false; const bKeySet = new Set(bKeys); for (const key of aKeys) { if (!bKeySet.has(key) || !deepEqual(a[key], b[key], seen)) return false; } return true;} const left = { a: NaN, b: -0, nested: [1, { x: 2 }] };const right = { nested: [1, { x: 2 }], b: 0, a: NaN };console.log(deepEqual(left, right));right.b = -0;console.log(deepEqual(left, right));left.self = left;right.self = right;console.log(deepEqual(left, right));console.log(deepEqual([1, 2], [2, 1]));falsetrueThis helper uses Object.is, so NaN equals NaN, but -0 and 0 are different. That is why the first check is false: b is -0 on the left and 0 on the right.
This lesson's helper uses these rules:
- Line 2 uses
Object.is. It works like===, except in two cases: it saysNaNequalsNaN, and it says-0is different from0. - Object keys are matched by name, so their order doesn't matter.
- Array items are matched by position, so their order does matter.
- Lines 4 to 7 handle cycles. A
WeakMaplinks each left object to aWeakSet, a set of the right objects it was already compared with. If the same pair comes up again, the helper says they are equal instead of going round the loop forever.
Where you'll use advanced recursion
You'll need these tools whenever front-end code walks through nested data and you don't know how deep it goes:
- Walking a folder of files that a user drags onto the page, to show the totals.
- Walking a tree of page elements in tests or in a text editor, when you can't trust its depth.
- Showing comment threads where replies hold more replies.
- Tidying up deeply nested JSON that a server sends back.
- Copying data before you send it to a worker or an iframe.
- Comparing two snapshots of a form, so a different key order doesn't look like a change.
The table below helps you pick a tool. Read each row as: the tool, what it does, and when it helps.
| Tool | What it does | When it helps |
|---|---|---|
| Tail call | Puts the call last, so nothing waits after it. | It makes code easy to follow. Most engines still add a stack frame for each call. |
| Trampoline | Returns a thunk for the next step, and a loop calls the thunks. | It keeps the recursive shape of your code when the depth can be large. |
| Explicit stack | Keeps the waiting items in an array, and loops. | It is the best everyday choice for huge trees, page elements, and folders of files. |
| CPS | Passes a callback that says what to do next. | It helps you understand callbacks and async code. On its own, it doesn't stop a stack overflow. |
If the depth comes from users, files, a content system, the page, or the network, expect it to be deeper than your test data. Use a trampoline or an explicit stack, and add a test with a very deep input.
Common misconceptions
"A tail call in JavaScript is always safe."
Not in Chrome, Node, or Firefox. A tail call is easy to follow, but those engines still add a stack frame for every call.
"A trampoline is a faster recursive function."
A trampoline swaps stack frames for loop turns, and it makes a new function for every step. It stops stack overflows. It doesn't make the code faster.
"CPS means the code runs later."
CPS only means you pass a callback for the next step. The callback can run right away, as in this lesson's example, or later, as with browser events and promises.
"structuredClone replaces every custom deep clone."
It is great for plain data. But it throws on functions and page elements, and it gives back a class object without its methods. A custom clone lets you choose your own rules.
"Deep equal has one right answer."
Teams choose their own rules for -0, class objects, Maps, Sets, arrays with gaps, and cycles. Write tests that show which rules you chose.
The table below lists four more wrong guesses. Read each row as: the guess, what is really true, and what to do instead.
| The guess | What is true | What to do |
|---|---|---|
| A tail call always saves stack space. | A tail call is about where the call sits in the code. Saving stack space is up to the engine. | Expect no saving outside Safari. |
| A returned thunk is the answer. | A thunk is one step of work, waiting to run. | Run it with a trampoline loop. |
| A WeakMap is only there for speed. | It keeps one copy for each object, and it stops loops in the data. | Save the pair before you clone or compare the children. |
| Order matters the same way in objects and arrays. | Key order in an object usually doesn't matter. Item order in an array does. | Write down your rule for order. |
Practice advanced recursion
5 EXERCISESRead the factorial with a tail call, and type the number it prints.
function factorialTail(n, acc = 1) {
if (n <= 1) return acc;
return factorialTail(n - 1, n * acc);
}
console.log(factorialTail(5));By the time n reaches 1, acc holds all the multiplication, so the program prints 120.
Predict whether the last line prints true or false.
function trampoline(task) {
let current = task;
let steps = 0;
while (typeof current === "function") {
current = current();
steps = steps + 1;
}
return { value: current, steps };
}
function sumThunk(n, acc = 0) {
if (n === 0) return acc;
return () => sumThunk(n - 1, acc + n);
}
const result = trampoline(() => sumThunk(100000));
console.log(result.steps > 100000);The loop calls the first thunk, then 100,000 more, so steps is 100,001. That is more than 100,000, so it prints true, with no stack overflow.
This deep clone has no seen map, so it never stops on data that points back to itself. Fix it. What should the fixed check print?
function deepClone(value) {
if (Object(value) !== value) return value;
const clone = Array.isArray(value) ? [] : {};
for (const key of Reflect.ownKeys(value)) {
clone[key] = deepClone(value[key]);
}
return clone;
}function deepClone(value, seen = new WeakMap()) {
if (Object(value) !== value || typeof value === "function") return value;
if (seen.has(value)) return seen.get(value);
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof Map) {
const clone = new Map();
seen.set(value, clone);
for (const [key, entry] of value) {
clone.set(deepClone(key, seen), deepClone(entry, seen));
}
return clone;
}
if (Array.isArray(value)) {
const clone = [];
seen.set(value, clone);
for (const item of value) clone.push(deepClone(item, seen));
return clone;
}
const clone = Object.create(Object.getPrototypeOf(value));
seen.set(value, clone);
for (const key of Reflect.ownKeys(value)) {
clone[key] = deepClone(value[key], seen);
}
return clone;
}
const profile = {
name: "Ada",
dates: [new Date("2020-01-02T00:00:00Z")],
meta: new Map([["visits", 3]]),
};
profile.self = profile;
const copy = deepClone(profile);
console.log(copy !== profile && copy.self === copy);The fixed helper checks seen first. It saves the copy before it copies the children. Then self points to the copy, instead of starting a new copy again and again.
Predict the two values that the rule check prints.
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) return Object.is(a.getTime(), b.getTime());
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) return false;
return a.every((item, index) => deepEqual(item, b[index], seen));
}
if (a instanceof Map) {
if (!(b instanceof Map) || a.size !== b.size) return false;
const aEntries = [...a.entries()];
const bEntries = [...b.entries()];
return aEntries.every(([key, value], index) => {
return deepEqual(key, bEntries[index][0], seen) && deepEqual(value, bEntries[index][1], seen);
});
}
const aKeys = Reflect.ownKeys(a);
const bKeys = Reflect.ownKeys(b);
if (aKeys.length !== bKeys.length) return false;
const bKeySet = new Set(bKeys);
for (const key of aKeys) {
if (!bKeySet.has(key) || !deepEqual(a[key], b[key], seen)) return false;
}
return true;
}
console.log(deepEqual(NaN, NaN) + "," + deepEqual(-0, 0));This lesson's rules say NaN equals itself, and -0 is different from 0. So the joined output is true,false.
A social site loads comment threads that users write. Some threads are tens of thousands of replies deep. Which tool should the page use first to walk them: a tail call, a trampoline, CPS, or an explicit stack?
const stack = [...comments];
while (stack.length > 0) {
const comment = stack.pop();
stack.push(...comment.replies);
}Walk a very deep comment tree with an explicit stack. The call stack stays one frame deep, and you can see the waiting work in the array.
Check your understanding
8 QUESTIONSFor each code question, ask three things. Is any work left after the call? Is a thunk still waiting for the loop? Which equality rules does the helper use? Each answer explains why it is right or wrong.
Question 1 of 8Which line has a call in tail position?
Choose an answer to see the explanation.
Question 2 of 8What does the tail and non-tail pair print?
Read the code, then predictfunction nonTail(n) { if (n === 0) return 0; return 1 + nonTail(n - 1); } function tail(n, acc = 0) { if (n === 0) return acc; return tail(n - 1, acc + 1); } console.log(nonTail(3), tail(3));Choose an answer to see the explanation.
Question 3 of 8Which sentence about proper tail calls is true today?
Choose an answer to see the explanation.
Question 4 of 8What does the small trampoline print?
Read the code, then predictfunction trampoline(task) { let current = task; let steps = 0; while (typeof current === "function") { current = current(); steps = steps + 1; } return { value: current, steps }; } function sumThunk(n, acc = 0) { if (n === 0) return acc; return () => sumThunk(n - 1, acc + n); } console.log(trampoline(() => sumThunk(3)).value);Choose an answer to see the explanation.
Question 5 of 8What does the CPS example print?
Read the code, then predictfunction sumCps(n, done) { if (n === 0) return done(0); return sumCps(n - 1, (smaller) => { return done(smaller + n); }); } sumCps(4, (answer) => console.log(answer));Choose an answer to see the explanation.
Question 6 of 8What does the cycle-aware clone check print?
Read the code, then predictfunction deepClone(value, seen = new WeakMap()) { if (Object(value) !== value || typeof value === "function") return value; if (seen.has(value)) return seen.get(value); if (value instanceof Date) return new Date(value.getTime()); if (value instanceof Map) { const clone = new Map(); seen.set(value, clone); for (const [key, entry] of value) { clone.set(deepClone(key, seen), deepClone(entry, seen)); } return clone; } if (Array.isArray(value)) { const clone = []; seen.set(value, clone); for (const item of value) clone.push(deepClone(item, seen)); return clone; } const clone = Object.create(Object.getPrototypeOf(value)); seen.set(value, clone); for (const key of Reflect.ownKeys(value)) { clone[key] = deepClone(value[key], seen); } return clone; } const item = { name: "node" }; item.self = item; const copy = deepClone(item); console.log(copy !== item && copy.self === copy);Choose an answer to see the explanation.
Question 7 of 8What does the deep-equal policy snippet print?
Read the code, then predictfunction 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) return Object.is(a.getTime(), b.getTime()); if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false; return a.every((item, index) => deepEqual(item, b[index], seen)); } if (a instanceof Map) { if (!(b instanceof Map) || a.size !== b.size) return false; const aEntries = [...a.entries()]; const bEntries = [...b.entries()]; return aEntries.every(([key, value], index) => { return deepEqual(key, bEntries[index][0], seen) && deepEqual(value, bEntries[index][1], seen); }); } const aKeys = Reflect.ownKeys(a); const bKeys = Reflect.ownKeys(b); if (aKeys.length !== bKeys.length) return false; const bKeySet = new Set(bKeys); for (const key of aKeys) { if (!bKeySet.has(key) || !deepEqual(a[key], b[key], seen)) return false; } return true; } console.log(deepEqual({ a: NaN }, { a: NaN })); console.log(deepEqual(-0, 0));Choose an answer to see the explanation.
Question 8 of 8What happens in Chrome or Node when this strict tail call goes 100,000 calls deep?
Read the code, then predict"use strict"; function dive(n) { if (n === 0) return "done"; return dive(n - 1); } console.log(dive(100000));Choose an answer to see the explanation.
Key takeaways
- A tail call is the last thing a function does. But most JavaScript engines still add a stack frame for it.
- A trampoline stops stack overflows: each step returns a thunk, and a loop calls the thunks one at a time.
- An explicit stack, an array plus a loop, is often the clearest way to walk a tree of unknown depth.
- CPS passes a callback that says what to do next. It is the same idea as callbacks and async code.
- Deep clone and deep equal need a
seenmap, usually aWeakMap, so data that points back to itself doesn't trap them.
Remember the one-liner.
Advanced recursion is about choosing where the next step waits: on the call stack, in a thunk, in an array, or in a callback.
Coming next: Transducers, functors & monads. There, you will work with values that you can map over and chain, step by step.