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

@studnicky/memoize exposes runtime operations at its root, schemas at @studnicky/memoize/entities, and type contracts at @studnicky/memoize/interfaces.

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[] = [];

  private static chargeIdKeyDeriver(id: string): string {
    if (id.length === 0 || id.trim().length === 0) {
      throw RuntimeError.create('Charge ID cannot be empty');
    }
    return `charge:${id}`;
  }

  static tracked(callback: (id: string) => Promise<{ 'chargeId': string }>): TelemetryMemoize {
    const result = TelemetryMemoize.create(callback, {
      'capacity': 1000,
      'keyDeriver': TelemetryMemoize.chargeIdKeyDeriver,
      'ttlMs': 60_000
    });
    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;
    const result: { 'chargeId': string } = { 'chargeId': `ch_${id}` };
    const promise = Promise.resolve(result);
    return promise;
  }
}

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

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

  static resolve(value: { 'chargeId': string }): void {
    SharedChargeResolver.resolveCallback(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();

Try it

Loading example…

The output shows onMemoMiss firing on the first call for a key, onMemoHit on a repeat call, a fresh onMemoMiss after invalidate() forces re-computation, and onMemoCoalesced when two concurrent callers share the same in-flight invocation.

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's hooks are specifically about memoization semantics (hit/miss/coalesced); implementation-level cache and coalescing state stays encapsulated.

Encapsulation contract

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

The composed LruCache and Coalesce remain private. Callers control cached state through invalidate() and clear(), and observe memoization behavior through the memo-specific hooks.

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
MemoizeConfigErrorMemoize.create(fn, options) receives an invalid function, key derivation, or cache capacity

Documentation

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

Entities

@studnicky/memoize/entities exports memoized cache lookup schemas.

typescript
import { CacheLookupEntity } from '@studnicky/memoize/entities';

Interfaces

@studnicky/memoize/interfaces exports memoization option contracts.

typescript
import type { MemoizeOptionsInterface } from '@studnicky/memoize/interfaces';

Exports

SymbolPurposeImport path
MemoizeWraps a function with cache-backed, single-flight memoization.@studnicky/memoize
MemoizeConfigErrorRepresents invalid memoization configuration.@studnicky/memoize
MemoizeErrorBase error for memoization failures.@studnicky/memoize

Source on GitHub