Skip to content

@studnicky/memoize

Pure function memoization composing @studnicky/cache and @studnicky/concurrency.

vs. @studnicky/idempotency-guard: both compose LruCache + Coalesce, but solve different problems. Memoize is pure memoization — the same derived key always replays the cached result, no conflict detection. IdempotencyGuard fingerprints a payload alongside the cached result and errors when a key is reused for a different payload. Pick IdempotencyGuard when key reuse with a different payload is a bug to catch; pick Memoize when the goal is simply caching a function's result.

Install

bash
pnpm add @studnicky/memoize

Usage

Memoize#call(...args) derives key = keyFn(...args) and checks the composed LruCache for an entry under key. A hit returns the cached result without re-invoking the wrapped function; a miss runs the call through the composed Coalesce so concurrent callers sharing the derived key share one invocation:

ts
import { Memoize } from '../src/index.js';

class TelemetryMemoize extends Memoize<[string], { 'chargeId': string }> {
  readonly events: string[] = [];

  static tracked(fn: (id: string) => Promise<{ 'chargeId': string }>): TelemetryMemoize {
    const keyFn = (id: string): string => {
      if (id.length === 0 || id.trim().length === 0) {
        throw new Error('Charge ID cannot be empty');
      }
      return `charge:${id}`;
    };
    const result = TelemetryMemoize.create(fn, {
      'capacity': 1000,
      'keyFn': keyFn,
      'ttlMs': 60_000
    }) as TelemetryMemoize;
    return result;
  }

  protected override onMemoHit(key: string): void {
    console.log(`[memoize] hit key=${key}`);
    this.events.push(`hit:${key}`);
  }

  protected override onMemoMiss(key: string): void {
    console.log(`[memoize] miss key=${key}`);
    this.events.push(`miss:${key}`);
  }

  protected override onMemoCoalesced(key: string): void {
    console.log(`[memoize] coalesced key=${key}`);
    this.events.push(`coalesced:${key}`);
  }
}

let fetchCalls = 0;

class Charge {
  static fetch(id: string): Promise<{ 'chargeId': string }> {
    fetchCalls += 1;
    return Promise.resolve({ 'chargeId': `ch_${id}` });
  }
}

// Concurrent calls with the same (new) key share one invocation via Coalesce
class SharedChargeResolver {
  private static resolveFn: (value: { 'chargeId': string }) => void = () => {};

  static capture(resolve: (value: { 'chargeId': string }) => void): void {
    SharedChargeResolver.resolveFn = resolve;
  }

  static resolve(value: { 'chargeId': string }): void {
    SharedChargeResolver.resolveFn(value);
  }
}

// Runs the full demo sequence and returns every value the assertions below need,
// so the module ends up with a single top-level binding rather than one per step.
class MemoizeDemoRunner {
  static async run(): Promise<{
    'first': { 'chargeId': string };
    'memo': TelemetryMemoize;
    'resultA': { 'chargeId': string };
    'resultB': { 'chargeId': string };
    'second': { 'chargeId': string };
    'sharedCalls': number;
    'sharedMemo': TelemetryMemoize;
    'third': { 'chargeId': string };
  }> {
    const memo = TelemetryMemoize.tracked(Charge.fetch);

    // New key -> onMemoMiss, fn runs
    const first = await memo.call('order-42');

    // Same key -> onMemoHit, fn does NOT run
    const second = await memo.call('order-42');

    // invalidate() forces the next matching call to re-invoke fn
    memo.invalidate('order-42');
    const third = await memo.call('order-42');

    const pending = new Promise<{ 'chargeId': string }>((resolve) => { SharedChargeResolver.capture(resolve); });
    let sharedCalls = 0;
    const sharedMemo = TelemetryMemoize.tracked(async () => {
      sharedCalls += 1;
      return await pending;
    });

    const callA = sharedMemo.call('order-99');
    const callB = sharedMemo.call('order-99');
    SharedChargeResolver.resolve({ 'chargeId': 'ch_shared' });
    const [resultA, resultB] = await Promise.all([callA, callB]);

    console.log('Events:', memo.events, sharedMemo.events);

    return { 'first': first, 'memo': memo, 'resultA': resultA, 'resultB': resultB, 'second': second, 'sharedCalls': sharedCalls, 'sharedMemo': sharedMemo, 'third': third };
  }
}

const demo = await MemoizeDemoRunner.run();

Hooks

HookFires when
onMemoHit(key, args)call() returns a cached result for key without re-invoking fn
onMemoMiss(key, args)key is genuinely new (or its entry expired) and fn is about to run
onMemoCoalesced(key, args)A caller joins an already in-flight invocation for key

Memoize introduces no hooks duplicating what LruCache/Coalesce already expose — its own hooks are specifically about memoization semantics (hit/miss/coalesced). Generic cache/coalesce lifecycle (onEvict, onExpire, onCoalesceSettled, onTimeout, ...) stays reachable via getCache()/getCoalesce() for a consumer who subclasses the composed instances directly.

Transparency contract

Memoize's own hooks (onMemoHit, onMemoMiss, onMemoCoalesced) are specifically about memoization semantics — never a restatement of generic cache/coalesce lifecycle:

GetterReturns
getCache()The composed LruCache<string, TResult> instance
getCoalesce()The composed Coalesce<TResult> instance

Every getter returns the exact instance used internally — never a copy or wrapper. A consumer who needs LruCache's onEvict/onExpire/onHit or Coalesce's onCoalesceSettled/onTimeout subclasses those primitives directly and passes nothing new through MemoizeMemoize composes LruCache/Coalesce internally rather than accepting them as injected config, so those getters are the only path to the live instances.

Composition order

call() derives key from args → checks the cache (onMemoHit short-circuits here) → on a miss, delegates to the composed Coalesce (onMemoMiss for the leader about to invoke the wrapped function, onMemoCoalesced for followers joining the in-flight call) → stores the result in the cache on success.

Errors

ErrorThrown when
MemoizeConfigErrorMemoizeBuilder#build() is called without fn, keyFn, or capacity

Documentation

Full reference: https://studnicky.github.io/substrate/packages/memoize

Source on GitHub