Maximizing TypeScript Performance with Advanced Memoization Techniques

Learn how to speed up your TypeScript applications by using advanced memoization techniques to cache expensive function calls effectively.

Memoization is a powerful technique that can significantly improve the performance of your TypeScript applications. It works by caching the result of expensive function calls and returning the cached result when the same inputs occur again, avoiding unnecessary computations.

In this tutorial, we'll cover the basics of memoization and then introduce advanced techniques such as handling multiple arguments, deep comparison, and leveraging TypeScript's strong typing to create safe and efficient memoized functions.

Let's start with a simple example. Suppose we have a slow function that computes the nth Fibonacci number recursively:

typescript
function fibonacci(n: number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

This is very inefficient for larger n because it recalculates the same values many times. Memoization can help by storing results for each input.

Here's a simple memoization function for a single-argument function:

typescript
function memoize<T extends (arg: any) => any>(fn: T): T {
  const cache = new Map<any, ReturnType<T>>();
  return ((arg: any) => {
    if (cache.has(arg)) {
      return cache.get(arg)!;
    }
    const result = fn(arg);
    cache.set(arg, result);
    return result;
  }) as T;
}

Let's apply this to our Fibonacci function:

typescript
const memoizedFibonacci = memoize(fibonacci);

console.log(memoizedFibonacci(40));

Now, the function runs much faster when you call it multiple times with the same input because it reuses the cached result. However, this simple memoize function only works for functions with one argument and uses strict equality, which may not work well for objects or multiple parameters.

To handle multiple arguments, we can serialize the arguments as a key to use in the cache:

typescript
function memoizeMultiArgs<T extends (...args: any[]) => any>(fn: T): T {
  const cache = new Map<string, ReturnType<T>>();
  return ((...args: any[]) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key)!;
    }
    const result = fn(...args);
    cache.set(key, result);
    return result;
  }) as T;
}

This works nicely but has limitations: JSON.stringify can be slow for large objects and won't handle functions or cyclical references. For better performance and deep comparison, you can use more advanced techniques like custom hashing or WeakMaps for object arguments.

Here's an example of memoizing functions with object arguments using WeakMap:

typescript
function memoizeObjectArgs<T extends (...args: any[]) => any>(fn: T): T {
  const rootCache = new WeakMap<object, any>();
  return function memoized(...args: any[]): any {
    let currentCache = rootCache;

    for (let i = 0; i < args.length - 1; i++) {
      const arg = args[i];
      if (typeof arg !== 'object' || arg === null) {
        // fallback for primitive values, use Map instead or serialize
        throw new Error('memoizeObjectArgs supports only object arguments');
      }
      if (!currentCache.has(arg)) {
        currentCache.set(arg, new WeakMap());
      }
      currentCache = currentCache.get(arg);
    }

    // Last argument cache
    const lastArg = args[args.length - 1];
    if (currentCache.has(lastArg)) {
      return currentCache.get(lastArg);
    }

    const result = fn(...args);
    currentCache.set(lastArg, result);
    return result;
  } as T;
}

This approach uses nested WeakMaps to cache results keyed by object references and is ideal when your function arguments are objects that don’t change reference often.

Finally, remember that memoization is great for pure, deterministic functions that don't have side effects and always return the same result given the same input. Avoid memoizing functions with random outputs, side effects, or where inputs change frequently.

To summarize, these advanced memoization techniques can help you optimize your TypeScript code by reducing expensive recalculations. Choose the right memoization strategy based on your function’s arguments and usage patterns.