@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/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:
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
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
| 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'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
| Error | Thrown when |
|---|---|
MemoizeConfigError | Memoize.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.
import { CacheLookupEntity } from '@studnicky/memoize/entities';Interfaces
@studnicky/memoize/interfaces exports memoization option contracts.
import type { MemoizeOptionsInterface } from '@studnicky/memoize/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
Memoize | Wraps a function with cache-backed, single-flight memoization. | @studnicky/memoize |
MemoizeConfigError | Represents invalid memoization configuration. | @studnicky/memoize |
MemoizeError | Base error for memoization failures. | @studnicky/memoize |