Learn JS Series):Exercise 1 - call and apply:
function sayCity() { return this.city; }
console.log(sayCity.call({ city: "amsterdam" })); // "amsterdam"
console.log(sayCity.apply({ city: "berlin" })); // "berlin"
The insight: with no arguments to pass, call and apply look identical; the difference only appears when you pass arguments (list versus array).
Exercise 2 - partial application with bind:
function multiply(a, b, c) { return a * b * c; }
const times12 = multiply.bind(null, 3, 4); // a=3, b=4 fixed
console.log(times12(2)); // 24
The insight: null is passed as this because multiply does not use it; the following arguments pre-fill a and b.
Exercise 3 - a bound logger:
const logger = {
prefix: "[LOG]",
write(msg) { return `${this.prefix} ${msg}`; },
};
const boundWrite = logger.write.bind(logger);
setTimeout(() => console.log(boundWrite("started")), 10); // "[LOG] started"
The insight: binding locks this to logger, so the detached function keeps the right prefix; unbound, this would be undefined.
With this finally nailed down over the last two episodes, we change gears completely. Today is about a single idea that trips up almost every beginner and then, once it clicks, becomes one of the most satisfying tools you own: functions that call themselves. Recursion.
Recursion is when a function calls itself to solve a problem by breaking it into smaller versions of the same problem. It sounds circular, and it would be an infinite loop, except for one crucial ingredient: every recursive function needs a base case, a condition where it stops calling itself and just returns an answer. Without a base case, it recurses forever (until it crashes, which we will see happen shortly).
So every recursive function has two parts, and I want you to memorize this template because it never changes:
The classic first example is factorial (n! = n times (n-1) times ... times 1). Read the two comments and you will see the template staring right back at you:
function factorial(n) {
if (n <= 1) return 1; // base case: 0! and 1! are 1
return n * factorial(n - 1); // recursive case: n * (n-1)!
}
console.log(factorial(5)); // 120 (5 * 4 * 3 * 2 * 1)
Trace it slowly, because this trace is the whole mental model. factorial(5) cannot return yet -- it needs the value of factorial(4) first. So it pauses, half-finished, and asks for factorial(4), which in turn pauses and asks for factorial(3), and so on down to factorial(1), which hits the base case and returns 1 immediately. NOW the paused calls can finish, in reverse order: factorial(2) becomes 2 * 1 = 2, then factorial(3) becomes 3 * 2 = 6, then 4 * 6 = 24, then 5 * 24 = 120. The descent goes down to the base case, and the answer bubbles back up. That down-then-up shape is recursion in a nutshell.
A second linear example makes the template even clearer -- summing an array by walking an index toward its end:
function sumList(arr, i = 0) {
if (i === arr.length) return 0; // base case: past the end, nothing left to add
return arr[i] + sumList(arr, i + 1); // recursive case: this element + the sum of the rest
}
console.log(sumList([10, 20, 30])); // 60
Every recursive function you will ever write is a variation on those two you just read: check for the base case first, otherwise do a little work and hand the smaller remainder to yourself. If you find your recursion misbehaving, nine times out of ten the base case is wrong, missing, or unreachable. Check it FIRST.
To really understand recursion, you must understand the call stack, the mechanism JavaScript uses to keep track of function calls (we go a lot deeper on it in Phase 7, but you need the essentials now). Every time you call a function, JavaScript pushes a "frame" onto the stack. That frame holds the call's local variables, its arguments, and a note of where to return to when the function finishes. When the function returns, its frame is popped off the top. The stack is strictly last-in-first-out, like a stack of plates -- you can only add to or take from the top.
Here is the connection to recursion: with a recursive function, each self-call adds another frame before the previous one has finished. The outer call is still sitting there, paused, waiting for its result. So the stack grows deeper and deeper as you descend, and only unwinds once the base case is reached:
function countdown(n) {
if (n === 0) {
console.log("liftoff");
return;
}
console.log(n);
countdown(n - 1); // a new frame stacks on top before this call returns
}
countdown(3); // 3, 2, 1, liftoff
Picture the stack at the deepest point of countdown(3). There are four frames piled up: countdown(3) at the bottom, then countdown(2), then countdown(1), then countdown(0) on top. All four exist AT THE SAME TIME, each holding its own n. Only when countdown(0) prints "liftoff" and returns do the frames pop off one by one, from the top down, until the stack is empty again. That growing-then-shrinking pile is exactly what you must picture to reason about ANY recursion, no matter how fancy. When people say recursion "feels like magic", it is almost always because they have not yet pictured the stack. Once you do, the magic turns into plain mechanics.
Now, that stack is not infinite. It is a finite region of memory, and every recursive call consumes one frame of it. So recursion that goes too deep -- or that forgets its base case entirely -- runs out of stack space and crashes with a stack overflow. In JavaScript the error reads Maximum call stack size exceeded (technically a RangeError):
function forever(n) {
return forever(n + 1); // no base case! recurses until the stack is full
}
// forever(0); // ERROR (runtime): Maximum call stack size exceeded
That one is an obvious bug -- no base case, so it can only ever crash. But here is the part that surprises people: even a perfectly correct recursion can overflow, purely because the depth is too large. Our sumList from earlier is completely correct, yet feed it a huge array and it will blow the stack, because it stacks one frame per element:
function sumTo(n) {
if (n === 0) return 0;
return n + sumTo(n - 1); // one frame per number - fine for small n
}
console.log(sumTo(1000)); // 500500 - works comfortably
// console.log(sumTo(100000)); // ERROR (runtime): Maximum call stack size exceeded
sumTo(1000) is fine, but sumTo(100000) tries to stack a hundred thousand frames and dies. A plain loop doing the same arithmetic would not even blink, because a loop reuses ONE frame no matter how many times it iterates. JavaScript engines typically allow only somewhere around ten-to-fifteen thousand nested frames (the exact number depends on the engine and how much each frame holds), so deep linear recursion is a real, practical constraint you must respect. This is precisely why, for simple linear repetition over large counts, a loop is the safer choice -- it cannot overflow.
If loops are safer for simple counting, why use recursion at all? Fair question. The answer is that some data is naturally recursive, and for that data, recursion is dramatically clearer than any loop. Trees, nested objects, folder structures on your disk, the DOM in a web page -- these are all recursive by nature: a node contains nodes that contain nodes, to an unknown depth. Recursion mirrors that shape perfectly, because a recursive function is literally "a thing that contains a smaller version of itself", which is the same sentence that describes the data.
Consider summing all the numbers in an arbitrarily nested array. This is genuinely painful with plain loops (you would need your own explicit stack), but it is elegant with recursion:
function deepSum(arr) {
let total = 0;
for (const item of arr) {
if (Array.isArray(item)) {
total += deepSum(item); // recurse into nested arrays
} else {
total += item; // base case: a plain number, just add it
}
}
return total;
}
console.log(deepSum([1, [2, 3, [4, 5]], 6])); // 21
The beautiful thing is that deepSum does not need to know, and does not care, how deep the nesting goes. Each level handles its own plain numbers and delegates any nested arrays to another call, which does exactly the same at its level. The structure could be three deep or thirty deep -- the code is identical. Walking a nested object is the very same idea, just reading values instead of array elements:
function countKeys(obj) {
let count = 0;
for (const value of Object.values(obj)) {
count += 1;
if (value && typeof value === "object") {
count += countKeys(value); // recurse into nested objects
}
}
return count;
}
console.log(countKeys({ a: 1, b: { c: 2, d: { e: 3 } } })); // 5
And once you see the pattern, real tree structures fall out naturally. Here is a little tree of nodes, each with a value and a list of children, summed with a handful of lines:
const tree = {
value: 1,
children: [
{ value: 2, children: [] },
{ value: 3, children: [{ value: 4, children: [] }] },
],
};
function sumTree(node) {
let total = node.value; // count this node
for (const child of node.children) { // then delegate each subtree to a recursive call
total += sumTree(child);
}
return total;
}
console.log(sumTree(tree)); // 10 (1 + 2 + 3 + 4)
Notice something important that separates these from countdown: this data BRANCHES. A node can have several children, so a single call can spawn several recursive calls, not just one. That branching is exactly where recursion stops being a mere alternative to a loop and becomes the natural, and often the only sane, way to express the problem. Try writing sumTree with plain loops and you will very quickly reinvent a stack of your own -- at which point you have written recursion the hard way. ;-)
So how do you actually choose between a loop and a recursion in practice? Here is a guideline I have leaned on for years. For simple, linear repetition -- count to n, sum a flat array, walk a list once -- prefer a loop. It is efficient, it is easy to read, and crucially it cannot overflow. For inherently nested or branching structures -- trees, nested data, folder trees, and divide-and-conquer algorithms like quicksort or binary search -- prefer recursion, because it matches the shape of the problem and keeps the code honest.
This next one is the poster child for "just use a loop". It is linear, the count can be enormous, and a loop handles it without breaking a sweat where recursion would overflow:
// linear task with a potentially huge count: a loop is the better fit
function sumTo(n) {
let total = 0;
for (let i = 1; i <= n; i++) total += i;
return total;
}
console.log(sumTo(1000000)); // 500000500000 - fine as a loop; recursion would overflow
It is worth knowing that ANY recursion can, in principle, be rewritten as a loop with an explicit stack that you manage by hand. Sometimes that is the right move (when depth is unbounded and could overflow). But it is often uglier and harder to follow, so the honest rule is: use recursion when it makes the code CLEARER and the depth is safely bounded; reach for a loop (or a manual stack) when the depth could be huge. The mental checklist is short. Is the data or algorithm naturally nested or branching? Recurse. Is it flat and linear, possibly with huge counts? Loop. Get that instinct right and recursion becomes a precise instrument in stead of a party trick.
Since quit a few of you arrived here from the Learn Python Series, with some Rust and Go readers mixed in, a look sideways is genuinely illuminating -- because the "deep recursion overflows the stack" story plays out very differently across languages, and it is not just trivia.
Python is actually STRICTER than JavaScript here. It ships a deliberate recursion limit (1000 by default) and raises a clean RecursionError long before the real machine stack is exhausted, as a safety guard. You can raise the ceiling with sys.setrecursionlimit, but the deeper lesson is the same as in JS: Python has no tail-call optimization either, so deep linear recursion is discouraged, and Pythonistas reach for a loop:
import sys
def sum_to(n):
if n == 0:
return 0
return n + sum_to(n - 1)
print(sum_to(900)) # fine, under the default limit
# print(sum_to(100000)) # RecursionError: maximum recursion depth exceeded
sys.setrecursionlimit(200000) # you CAN raise it, but a loop is wiser
Rust has no this and no runtime recursion guard -- it simply recurses on the real stack, and if you go too deep the program aborts with a stack overflow, exactly like JavaScript, just less politely. Rust does not guarantee tail-call optimization either, so idiomatic Rust for a linear job is a plain loop, which never touches stack depth at all:
fn sum_to(n: u64) -> u64 {
let mut total = 0;
for i in 1..=n {
total += i; // a loop: constant stack usage, no overflow ever
}
total
}
fn main() {
println!("{}", sum_to(1_000_000)); // 500000500000
}
Go is the interesting outlier. Goroutines start with a tiny stack (a few kilobytes) that GROWS automatically as needed, up to a large limit (a gigabyte by default on 64-bit systems). So Go tolerates FAR deeper recursion than JavaScript or Python before it ever complains -- the stack quietly resizes under you:
package main
import "fmt"
func sumTo(n int) int {
if n == 0 {
return 0
}
return n + sumTo(n-1) // Go's growable stack handles very deep recursion
}
func main() {
fmt.Println(sumTo(1000000)) // works - the goroutine stack grew to fit
}
So the same recursive function overflows quickly in JS and Python, less quickly (but still) in Rust, and survives enormous depths in Go. The takeaway is not "Go is best" -- it is that stack depth is an implementation reality of your runtime, not a property of recursion itself. As an aside for the curious: the JavaScript spec actually DID add proper tail calls in ES2015, which would let certain recursions run in constant stack space, but in practice only Safari's engine ever shipped it -- V8 (so Node and Chrome) never did. That is why, in the JavaScript you will actually run, deep linear recursion overflows and a loop is the pragmatic answer. Having said that, for nested and branching data, recursion remains the clear winner in every one of these languages. ;-)
Three exercises, increasing in difficulty. Write the base case FIRST every time, then the recursive case -- and predict the output before you run it. Full solutions open the next episode.
power(base, exponent) that computes base to the exponent (assume a non-negative integer exponent). Identify your base case explicitly in a comment. Test power(2, 10).flatten(arr) that turns an arbitrarily nested array of numbers into a flat array, so flatten([1, [2, [3, 4]], 5]) returns [1, 2, 3, 4, 5]. (Hint: check Array.isArray and concatenate results.)3.Next episode we look at IIFEs and the module pattern, the clever pre-2015 technique that used functions and closures to create privacy and organize code before JavaScript had real modules.