Skip to content
VersionSize

memo

The memo utility creates a memoized version of a function that caches its results based on the provided arguments. It is highly configurable, featuring support for Time-To-Live (TTL) expiration and a maximum cache size with LRU (Least Recently Used) eviction.

Source Code

View Source Code
ts
import type { Fn } from '../types';

type MemoOptions<T extends Fn> = {
  key?: (...args: Parameters<T>) => PropertyKey;
  maxSize?: number;
  ttl?: number;
};

type CacheEntry<T> = {
  expiresAt: number;
  value: T;
};

const UNDEFINED_SENTINEL = '\x00undefined\x00';

const defaultKey = (args: unknown[]): string => {
  try {
    return JSON.stringify(args, (_, value) => (value === undefined ? UNDEFINED_SENTINEL : value));
  } catch (error) {
    const reason = error instanceof Error && error.message ? ` Reason: ${error.message}` : '';

    throw new TypeError(
      `[toolkit/memo] Failed to serialize memo arguments. Provide options.key for non-serializable arguments.${reason}`,
      { cause: error },
    );
  }
};

const isPromise = (value: unknown): value is Promise<unknown> =>
  typeof value === 'object' &&
  value !== null &&
  'then' in value &&
  typeof (value as Promise<unknown>).then === 'function';

/**
 * Creates a function that memoizes the result of the provided function.
 * Supports sync and async functions, including in-flight deduplication for async calls.
 *
 * @example
 * ```ts
 * const add = (x, y) => x + y;
 * const memoizedAdd = memo(add, { ttl: 5000, maxSize: 10 });
 *
 * memoizedAdd(1, 2); // 3 (caches the result)
 * memoizedAdd(1, 2); // 3 (from cache)
 * ```
 *
 * @param fn - The function to memorize.
 * @param options - Memoization options.
 * @param [options.ttl] - (optional) time-to-live (TTL) for cache expiration (in milliseconds).
 * @param [options.maxSize] - (optional) maximum cache size (LRU eviction).
 * @param [options.key] - (optional) custom function to resolve the cache key.
 *
 * @returns A new function that memorizes the input function.
 */
export function memo<T extends Fn>(
  fn: T,
  { key, maxSize = Infinity, ttl = Infinity }: MemoOptions<T> = {},
): (...args: Parameters<T>) => ReturnType<T> {
  const cache = new Map<PropertyKey, CacheEntry<ReturnType<T>>>();

  return (...args: Parameters<T>): ReturnType<T> => {
    const cacheKey = key ? key(...args) : defaultKey(args);
    const now = Date.now();
    const cached = cache.get(cacheKey);

    if (cached && cached.expiresAt > now) {
      cache.delete(cacheKey);
      cache.set(cacheKey, cached);

      return cached.value;
    }

    const result = fn(...args);
    const entry: CacheEntry<ReturnType<T>> = { expiresAt: now + ttl, value: result as ReturnType<T> };

    cache.delete(cacheKey);
    cache.set(cacheKey, entry);

    if (isPromise(result)) {
      // Evict on rejection so subsequent calls retry instead of returning a settled failure
      void result.catch(() => cache.delete(cacheKey));
    }

    while (cache.size > maxSize) {
      const oldestKey = cache.keys().next().value;

      if (oldestKey === undefined) {
        break;
      }

      cache.delete(oldestKey);
    }

    return result as ReturnType<T>;
  };
}

Features

  • Isomorphic: Works in both Browser and Node.js.
  • Smart Caching: Efficiently stores and retrieves results for pure functions.
  • Cache Management: Prevent memory leaks with maxSize and ttl options.
  • LRU Eviction: Automatically removes the oldest entries when the cache limit is reached.
  • Type-safe: Properly preserves the original function's signature and return type.

API

Type Definitions
ts
import type { Fn } from '../types';

type MemoOptions<T extends Fn> = {
  key?: (...args: Parameters<T>) => PropertyKey;
  maxSize?: number;
  ttl?: number;
};

type CacheEntry<T> = {
  expiresAt: number;
  value: T;
};

const UNDEFINED_SENTINEL = '\x00undefined\x00';

const defaultKey = (args: unknown[]): string => {
  try {
    return JSON.stringify(args, (_, value) => (value === undefined ? UNDEFINED_SENTINEL : value));
  } catch (error) {
    const reason = error instanceof Error && error.message ? ` Reason: ${error.message}` : '';

    throw new TypeError(
      `[toolkit/memo] Failed to serialize memo arguments. Provide options.key for non-serializable arguments.${reason}`,
      { cause: error },
    );
  }
};

const isPromise = (value: unknown): value is Promise<unknown> =>
  typeof value === 'object' &&
  value !== null &&
  'then' in value &&
  typeof (value as Promise<unknown>).then === 'function';

/**
 * Creates a function that memoizes the result of the provided function.
 * Supports sync and async functions, including in-flight deduplication for async calls.
 *
 * @example
 * ```ts
 * const add = (x, y) => x + y;
 * const memoizedAdd = memo(add, { ttl: 5000, maxSize: 10 });
 *
 * memoizedAdd(1, 2); // 3 (caches the result)
 * memoizedAdd(1, 2); // 3 (from cache)
 * ```
 *
 * @param fn - The function to memorize.
 * @param options - Memoization options.
 * @param [options.ttl] - (optional) time-to-live (TTL) for cache expiration (in milliseconds).
 * @param [options.maxSize] - (optional) maximum cache size (LRU eviction).
 * @param [options.key] - (optional) custom function to resolve the cache key.
 *
 * @returns A new function that memorizes the input function.
 */
export function memo<T extends Fn>(
  fn: T,
  { key, maxSize = Infinity, ttl = Infinity }: MemoOptions<T> = {},
): (...args: Parameters<T>) => ReturnType<T> {
  const cache = new Map<PropertyKey, CacheEntry<ReturnType<T>>>();

  return (...args: Parameters<T>): ReturnType<T> => {
    const cacheKey = key ? key(...args) : defaultKey(args);
    const now = Date.now();
    const cached = cache.get(cacheKey);

    if (cached && cached.expiresAt > now) {
      cache.delete(cacheKey);
      cache.set(cacheKey, cached);

      return cached.value;
    }

    const result = fn(...args);
    const entry: CacheEntry<ReturnType<T>> = { expiresAt: now + ttl, value: result as ReturnType<T> };

    cache.delete(cacheKey);
    cache.set(cacheKey, entry);

    if (isPromise(result)) {
      // Evict on rejection so subsequent calls retry instead of returning a settled failure
      void result.catch(() => cache.delete(cacheKey));
    }

    while (cache.size > maxSize) {
      const oldestKey = cache.keys().next().value;

      if (oldestKey === undefined) {
        break;
      }

      cache.delete(oldestKey);
    }

    return result as ReturnType<T>;
  };
}
ts
function memo<T extends (...args: any[]) => any>(fn: T, options?: MemoizeOptions<T>): T;

Parameters

  • fn: The function to memoize.
  • options: Optional configuration:
    • ttl: Time in milliseconds before a cached result expires.
    • maxSize: Maximum number of entries to keep in the cache.

Returns

  • A new function that caches results.

Examples

Basic Memoization

ts
import { memo } from '@vielzeug/toolkit';

const heavyCalculation = (n: number) => {
  console.log('Calculating...');
  return n * n;
};

const cachedCalc = memo(heavyCalculation);

cachedCalc(5); // Logs 'Calculating...', returns 25
cachedCalc(5); // Returns 25 immediately (from cache)

With Expiration (TTL)

ts
import { memo } from '@vielzeug/toolkit';

// Cache results for only 1 minute
const getStats = memo(fetchStats, { ttl: 60000 });

Limiting Memory Usage

ts
import { memo } from '@vielzeug/toolkit';

// Keep only the last 100 results
const formatData = memo(formatter, { maxSize: 100 });

Implementation Notes

  • Performance-optimized using a standard Map for the cache.
  • The cache key is generated based on the string representation of all arguments.
  • Throws TypeError if fn is not a function.

See Also

  • once: Cache a result that never changes.
  • retry: Automatically re-run failed operations.
  • throttle: Rate-limit execution based on time.