Skip to main content

Recursion

Recursion is a function calling itself with a smaller input until it reaches a base case. It's the natural way to solve problems with self-similar structure: trees, graphs, divide-and-conquer.

The Two Rules​

  1. Base case — a condition that stops the recursion
  2. Recursive case — the function calls itself with a simpler input
// Classic: factorial
function factorial(n: number): number {
if (n <= 1) return 1; // base case
return n * factorial(n - 1); // recursive case
}

factorial(5); // 5 * 4 * 3 * 2 * 1 = 120

How the Call Stack Works​

Each recursive call adds a stack frame. For factorial(4):

factorial(4)
→ 4 * factorial(3)
→ 3 * factorial(2)
→ 2 * factorial(1)
→ 1 ← base case
← 2 * 1 = 2
← 3 * 2 = 6
← 4 * 6 = 24

Stack overflow happens when recursion is too deep (default ~10,000 frames in V8).

Fibonacci — Naive vs Memoized​

// Naive: O(2^n) — exponential, terrible
function fibNaive(n: number): number {
if (n <= 1) return n;
return fibNaive(n - 1) + fibNaive(n - 2);
}

// Memoized: O(n) — cache results
function fibMemo(n: number, memo = new Map<number, number>()): number {
if (n <= 1) return n;
if (memo.has(n)) return memo.get(n)!;
const result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
memo.set(n, result);
return result;
}

fibMemo(50); // instant

Tree Traversal​

Recursion is perfect for tree structures because each subtree is also a tree.

interface TreeNode {
value: number;
left?: TreeNode;
right?: TreeNode;
}

// Sum all values in a tree
function sumTree(node: TreeNode | undefined): number {
if (!node) return 0;
return node.value + sumTree(node.left) + sumTree(node.right);
}

// Find maximum depth
function maxDepth(node: TreeNode | undefined): number {
if (!node) return 0;
return 1 + Math.max(maxDepth(node.left), maxDepth(node.right));
}

// Flatten a tree to array (in-order)
function flatten(node: TreeNode | undefined): number[] {
if (!node) return [];
return [...flatten(node.left), node.value, ...flatten(node.right)];
}

Flattening Nested Structures​

type NestedArray<T> = (T | NestedArray<T>)[];

function deepFlatten<T>(arr: NestedArray<T>): T[] {
return arr.reduce<T[]>((acc, item) => {
if (Array.isArray(item)) return [...acc, ...deepFlatten(item)];
return [...acc, item];
}, []);
}

deepFlatten([1, [2, [3, [4]], 5]]); // [1, 2, 3, 4, 5]

File System Traversal​

Real-world recursion: traversing directories.

import { readdirSync, statSync } from 'fs';
import { join } from 'path';

function getAllFiles(dir: string, ext?: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];

for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);

if (stat.isDirectory()) {
files.push(...getAllFiles(fullPath, ext)); // recurse
} else if (!ext || entry.endsWith(ext)) {
files.push(fullPath);
}
}

return files;
}

// Get all TypeScript files
getAllFiles('./src', '.ts');

Tail Call Optimization​

Tail recursion — the recursive call is the last operation. Some runtimes (not V8 currently) optimize this to avoid stack growth.

// Regular recursion — builds stack
function sum(n: number): number {
if (n <= 0) return 0;
return n + sum(n - 1);
}

// Tail recursive — accumulator carries the result
function sumTail(n: number, acc = 0): number {
if (n <= 0) return acc;
return sumTail(n - 1, acc + n); // tail call
}

// Iterative equivalent — always prefer for large n
function sumIter(n: number): number {
let acc = 0;
while (n > 0) acc += n--;
return acc;
}

When to Use Recursion vs Iteration​

Use recursion whenUse iteration when
Input is tree/graph shapedInput is linear (array)
Divide-and-conquer is naturalPerformance is critical
Code clarity matters moreStack depth could be large
Depth is bounded and knownLanguage lacks TCO

Practice Problems​

  1. Implement JSON.stringify recursively for objects and arrays
  2. Flatten a nested object: { a: { b: { c: 1 } } } → { 'a.b.c': 1 }
  3. Find all permutations of a string
  4. Solve the Tower of Hanoi
  5. Implement deep clone (handles objects, arrays, primitives)