Closures Deep Dive - Currying & Memoization

Difficulty: Advanced

Closures are one of the most powerful concepts in JavaScript, and currying and memoization are two of the most impactful patterns that rely on them. A closure occurs when a function retains access to variables from its enclosing scope even after that outer function has returned. This is not a special feature you opt into - it is the default behavior of functions in JavaScript due to lexical scoping. Currying and memoization both exploit this capability to create sophisticated, reusable function patterns.

Currying is the technique of transforming a function that takes multiple arguments into a sequence of functions that each take a single argument. Instead of calling f(a, b, c), you call f(a)(b)(c). Each intermediate call returns a new function that remembers the previously passed arguments via closure. This is not just an academic exercise - currying enables partial application, where you fix some arguments upfront and pass the rest later. This is enormously useful for creating specialized versions of general functions, such as pre-configuring loggers, validators, or event handlers.

Practical currying shows up in many real-world scenarios. Consider an event handler factory: a curried function that takes a configuration (like log level or analytics category) and returns a handler function. In React, curried functions are commonly used to create onClick handlers that need to know which item was clicked. In functional programming libraries like Ramda, every function is auto-curried by default. Understanding currying transforms how you think about function composition and reuse.

Memoization is an optimization technique where you cache the results of expensive function calls and return the cached result when the same inputs occur again. The cache is stored in a closure - the memoized wrapper function closes over an object (or Map) that maps inputs to outputs. When the function is called, it first checks the cache; if the input has been seen before, it returns the cached value immediately without recomputing. This can turn O(2^n) recursive algorithms into O(n) by eliminating redundant computations.

The classic memoization example is the Fibonacci sequence. A naive recursive implementation has exponential time complexity because it recalculates the same values over and over - fib(5) calculates fib(3) twice, fib(2) three times, and so on. With memoization, each value is computed only once and stored in the cache. The improvement is dramatic: calculating fib(40) goes from billions of operations to just 40. Writing a generic memoize utility function is a very common interview question, and understanding how it works with closures is essential.

Both currying and memoization demonstrate why closures are not just a theoretical concept but a practical tool. They show how functions can carry private state (curried arguments, cached results) without relying on global variables or class instances. This functional approach to state management leads to code that is more modular, testable, and composable.

Code examples

Currying - transforming multi-arg into single-arg chain

// Regular function
function add(a, b, c) {
  return a + b + c;
}

// Curried version
function curriedAdd(a) {
  return function(b) {
    return function(c) {
      return a + b + c; // closure over a and b
    };
  };
}

console.log(add(1, 2, 3));
console.log(curriedAdd(1)(2)(3));

// Partial application - fix some args now, rest later
const addFive = curriedAdd(5);
const addFiveAndTen = addFive(10);
console.log(addFiveAndTen(3)); // 5 + 10 + 3
console.log(addFiveAndTen(7)); // 5 + 10 + 7

// Arrow function syntax (concise)
const multiply = a => b => c => a * b * c;
console.log(multiply(2)(3)(4));

curriedAdd returns a chain of functions, each closing over previously passed arguments. addFive is a partially applied function - it remembers a=5 and waits for b and c. addFiveAndTen remembers a=5 and b=10, ready for the final argument. Each intermediate function creates a closure that persists until the innermost function executes.

Practical currying - event handlers and config

// Logger factory - curried configuration
const createLogger = (level) => (module) => (message) => {
  const timestamp = new Date().toISOString().slice(11, 19);
  return `[${timestamp}] [${level}] [${module}] ${message}`;
};

const errorLog = createLogger('ERROR');
const apiError = errorLog('API');
const dbError = errorLog('DB');

console.log(apiError('Connection timeout'));
console.log(dbError('Query failed'));
console.log(createLogger('INFO')('AUTH')('User logged in'));

// URL builder - reusable base
const buildUrl = (base) => (path) => (params) => {
  const query = Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&');
  return `${base}${path}?${query}`;
};

const api = buildUrl('https://api.example.com');
const usersEndpoint = api('/users');

console.log(usersEndpoint({ page: 1, limit: 10 }));
console.log(usersEndpoint({ page: 2, limit: 10 }));

The logger factory shows practical currying: you configure the level once to get a level-specific logger, then configure the module to get a module-specific logger. The URL builder demonstrates how currying creates reusable, composable function pieces - the base and path are fixed, and only the params change per call.

Memoization - caching function results

// Generic memoize utility
function memoize(fn) {
  const cache = new Map(); // closure over cache
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      console.log(`  Cache hit for ${key}`);
      return cache.get(key);
    }
    console.log(`  Computing for ${key}`);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const expensiveSquare = memoize((n) => {
  // Simulate expensive computation
  return n * n;
});

console.log(expensiveSquare(4));
console.log(expensiveSquare(4)); // cached!
console.log(expensiveSquare(5));
console.log(expensiveSquare(5)); // cached!

The memoize function returns a wrapper that closes over a Map cache. On each call, it serializes the arguments as a key. If the key exists in the cache, it returns the stored result immediately. If not, it computes the result, stores it, and returns it. The cache persists across calls because the wrapper function maintains a closure over it.

Fibonacci with memoization - exponential to linear

// Naive recursive Fibonacci - O(2^n)
function fibSlow(n) {
  if (n <= 1) return n;
  return fibSlow(n - 1) + fibSlow(n - 2);
}

// Memoized Fibonacci - O(n)
function fibFast(n, memo = {}) {
  if (n in memo) return memo[n];
  if (n <= 1) return n;
  memo[n] = fibFast(n - 1, memo) + fibFast(n - 2, memo);
  return memo[n];
}

// Compare performance
console.log('fibSlow(10):', fibSlow(10));
console.log('fibFast(10):', fibFast(10));
console.log('fibFast(50):', fibFast(50));
// fibSlow(50) would take minutes; fibFast(50) is instant

// Using our generic memoize utility
const fib = memoize(function(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});

console.log('fib(10):', fib(10));

fibSlow recalculates fib(3) multiple times when computing fib(5). With memoization, each value is computed once and stored - subsequent lookups are O(1). fibFast(50) computes instantly, while fibSlow(50) would take an impractical amount of time. The memo object is passed through the recursive calls, serving as a shared cache.

Key points

Concepts covered

currying, partial application, memoization, closure, higher-order functions, function factories, caching