@studnicky/memoize
Pure function memoization composing
@studnicky/cacheand@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
pnpm add @studnicky/memoizeUsage
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:
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
| Hook | Fires 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:
| Getter | Returns |
|---|---|
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 Memoize — Memoize 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
| Error | Thrown when |
|---|---|
MemoizeConfigError | MemoizeBuilder#build() is called without fn, keyFn, or capacity |
Documentation
Full reference: https://studnicky.github.io/substrate/packages/memoize