Lesson 7 of 8

Recursion in FP

Solving problems with recursive functions and tail call optimization

What is Recursion?

Recursion is when a function calls itself to solve a problem by breaking it into smaller subproblems. In functional programming, recursion is preferred over loops because it avoids mutation and works naturally with immutable data.

// Classic example: factorial
// 5! = 5 × 4 × 3 × 2 × 1 = 120

// Imperative with loop (mutates counter)
function factorialLoop(n) {
  let result = 1;
  for (let i = n; i > 1; i--) {
    result *= i;
  }
  return result;
}

// Recursive (no mutation)
function factorial(n) {
  if (n <= 1) return 1;        // Base case
  return n * factorial(n - 1); // Recursive case
}

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

🔑 Two Essential Parts of Recursion

  • Base Case: The condition that stops recursion. Without it, you get infinite recursion (stack overflow).
  • Recursive Case: The function calls itself with a smaller/simpler input, moving toward the base case.

Common Recursive Patterns

// Sum of array
const sum = arr => {
  if (arr.length === 0) return 0;          // Base case
  const [head, ...tail] = arr;
  return head + sum(tail);                  // Recursive case
};
sum([1, 2, 3, 4, 5]); // 15

// Find in array
const find = (arr, predicate) => {
  if (arr.length === 0) return undefined;
  const [head, ...tail] = arr;
  return predicate(head) ? head : find(tail, predicate);
};
find([1, 2, 3, 4], n => n > 2); // 3

// Map with recursion
const map = (arr, fn) => {
  if (arr.length === 0) return [];
  const [head, ...tail] = arr;
  return [fn(head), ...map(tail, fn)];
};
map([1, 2, 3], n => n * 2); // [2, 4, 6]

// Filter with recursion
const filter = (arr, pred) => {
  if (arr.length === 0) return [];
  const [head, ...tail] = arr;
  return pred(head) 
    ? [head, ...filter(tail, pred)]
    : filter(tail, pred);
};
filter([1, 2, 3, 4], n => n % 2 === 0); // [2, 4]

// Reduce with recursion
const reduce = (arr, fn, acc) => {
  if (arr.length === 0) return acc;
  const [head, ...tail] = arr;
  return reduce(tail, fn, fn(acc, head));
};
reduce([1, 2, 3, 4], (a, b) => a + b, 0); // 10

Tree Traversal

// Recursion shines with tree structures
const tree = {
  value: 1,
  children: [
    { value: 2, children: [
      { value: 4, children: [] },
      { value: 5, children: [] }
    ]},
    { value: 3, children: [
      { value: 6, children: [] }
    ]}
  ]
};

// Sum all values in tree
const sumTree = node => {
  const childSum = node.children.reduce(
    (sum, child) => sum + sumTree(child), 
    0
  );
  return node.value + childSum;
};
sumTree(tree); // 21

// Find in tree
const findInTree = (node, predicate) => {
  if (predicate(node)) return node;
  for (const child of node.children) {
    const found = findInTree(child, predicate);
    if (found) return found;
  }
  return null;
};
findInTree(tree, n => n.value === 5); // { value: 5, children: [] }

// Map over tree
const mapTree = (node, fn) => ({
  value: fn(node.value),
  children: node.children.map(child => mapTree(child, fn))
});
mapTree(tree, x => x * 10);
// All values multiplied by 10

// Flatten tree to array
const flatten = node => [
  node.value,
  ...node.children.flatMap(flatten)
];
flatten(tree); // [1, 2, 4, 5, 3, 6]

Tail Call Optimization

Normal recursion builds up a call stack. Tail call optimization (TCO) allows the engine to reuse the current stack frame when the recursive call is the last operation.

// NOT tail-recursive (operation after recursive call)
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1); // Multiply AFTER recursive call
}

// Tail-recursive (recursive call is the last thing)
function factorialTail(n, accumulator = 1) {
  if (n <= 1) return accumulator;
  return factorialTail(n - 1, n * accumulator); // Nothing after
}

factorialTail(5);
// factorialTail(5, 1)
// factorialTail(4, 5)     // Reuse stack frame
// factorialTail(3, 20)    // Reuse stack frame
// factorialTail(2, 60)    // Reuse stack frame
// factorialTail(1, 120)   // Reuse stack frame
// 120

// Converting to tail-recursive
// Non-tail: sum uses result after recursive call
const sum = arr => {
  if (arr.length === 0) return 0;
  return arr[0] + sum(arr.slice(1));
};

// Tail-recursive: use accumulator
const sumTail = (arr, acc = 0) => {
  if (arr.length === 0) return acc;
  return sumTail(arr.slice(1), acc + arr[0]);
};

// Note: TCO is only guaranteed in strict mode in Safari
// Other browsers may not implement it

Trampolining

Since TCO isn't widely supported, we can use trampolining to avoid stack overflow.

// Trampoline converts recursion to iteration
function trampoline(fn) {
  return function(...args) {
    let result = fn(...args);
    while (typeof result === 'function') {
      result = result();
    }
    return result;
  };
}

// Instead of calling itself, return a function to call
function sumRecursive(arr, acc = 0) {
  if (arr.length === 0) return acc;
  // Return a thunk (function) instead of recursing
  return () => sumRecursive(arr.slice(1), acc + arr[0]);
}

const sum = trampoline(sumRecursive);
sum([1, 2, 3, 4, 5]); // 15

// Works with large arrays that would overflow stack
const bigArray = Array.from({ length: 100000 }, (_, i) => i);
sum(bigArray); // 4999950000 (no stack overflow!)

Real-World Recursive Examples

// Deep clone object
const deepClone = obj => {
  if (obj === null || typeof obj !== 'object') return obj;
  if (Array.isArray(obj)) return obj.map(deepClone);
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) => [k, deepClone(v)])
  );
};

// Flatten nested arrays
const flattenDeep = arr => 
  arr.reduce((flat, item) =>
    flat.concat(Array.isArray(item) ? flattenDeep(item) : item),
    []
  );
flattenDeep([1, [2, [3, [4]]]]); // [1, 2, 3, 4]

// Get nested property safely
const getPath = (obj, path) => {
  if (path.length === 0) return obj;
  if (obj == null) return undefined;
  const [head, ...tail] = path;
  return getPath(obj[head], tail);
};
getPath({ a: { b: { c: 1 }}}, ['a', 'b', 'c']); // 1

// Fibonacci with memoization
const memoize = fn => {
  const cache = new Map();
  return n => {
    if (cache.has(n)) return cache.get(n);
    const result = fn(n);
    cache.set(n, result);
    return result;
  };
};

const fib = memoize(n => 
  n <= 1 ? n : fib(n - 1) + fib(n - 2)
);
fib(50); // 12586269025 (instant with memoization)

✅ Recursion Best Practices

  • • Always define a clear base case first
  • • Ensure progress toward the base case on each call
  • • Consider memoization for overlapping subproblems
  • • Use trampolining for large recursive operations
  • • Write tail-recursive when possible for performance
  • • Use built-in methods (map, filter, reduce) when they suffice