memotuple
v0.1.0
Published
Memoization with explicit identity, structural-value, and self-disabling key semantics.
Maintainers
Readme
memotuple
What does “the same call” mean?
memoizeByIdentity: the same object/function identities and the same primitive values.memoizeByValue: the same supported structure, even when rebuilt as fresh objects.memoizeSmart: identity sameness while it pays; a direct call forever after measured lookup cost exceeds expected savings.
import { memoizeByIdentity, memoizeByValue, memoizeSmart } from 'memotuple'
const byIdentity = memoizeByIdentity(render)
byIdentity(model, 'compact') // object identity + primitive value
const byValue = memoizeByValue(compile)
byValue({ target: 'node', minify: true })
byValue({ minify: true, target: 'node' }) // HIT: equal structure
const smart = memoizeSmart(cheapMaybeReusableWork)
smart.stats
smart.disabled // one-way and observableESM and TypeScript declarations included. Node 18+.
Why this exists
A memoizer is a claim about equivalence. Hiding that claim behind “hash the arguments somehow” creates two failure modes: surprising hits, and a fixed-size digest with a nonzero collision ceiling that can silently return the wrong answer. memotuple names the equivalence and uses representations that cannot digest-collide:
- identity keys are a
tuple-keyed-maptrie, not a hash; - structural keys are
valuehashcanonical strings, not a digest; and - an unkeyable structural call throws valuehash's error rather than becoming a silent miss.
The other recurring failure is economic: memoizing a cheap function over unique
arguments can cost hundreds or thousands of times more than calling it.
memoizeSmart samples exponentially spaced windows, estimates when expected
savings have turned negative, releases its cache, and becomes a passthrough.
Here is the executable matrix printed by npm run bench:
| case | byValue | byIdentity / smart | moize | memoizee | lru-cache idiom |
|---|---|---|---|---|---|
| distinct-but-equal objects | HIT | miss | miss | miss | miss |
| object mutated after call | re-key / recompute | identity hit | identity hit | identity hit | identity hit |
| dropped object arg releases entry | no | yes | no | no | no |
| digest-collision ceiling | N/A — exact key | N/A — trie | N/A — no digest | N/A — no digest | N/A — no digest |
The collision row does not claim these incumbents use digests; they do not. It states the stronger property memotuple is built around: neither of its key strategies compresses calls into a fixed-width digest. Hash memoizers necessarily have a nonzero wrong-answer ceiling.
The GC story
memoizeByIdentity and memoizeSmart dogfood
tuple-keyed-map. Every
object/function argument is a WeakMap edge, so dropping any object component
can make the entry, result, and remaining subtree collectable. There is no
registry race and no enumerability fiction.
The exact boundary matters:
- all-primitive identity tuples contain no mortal object, so they remain until
clear()or until a smart memoizer disables itself; memoizeByValuestores canonical strings strongly and usesmaxEntriesfor bounded LRU retention; andweakResults: truestores object/function results throughWeakRef. Collection turns the next lookup into a miss and recomputation. A dedicated sentinel represents a cachedundefined, so stored undefined is never mistaken for a collected weak result.
API
memoizeByIdentity(fn, options?)
const memoized = memoizeByIdentity(fn, {
weakResults?: boolean
})Argument components use SameValue for primitives (NaN hits NaN; -0 and
0 are distinct) and identity for objects/functions. Arity is part of the key.
The returned function exposes .cache (an IdentityCache) and .clear().
memoizeByValue(fn, options?)
const memoized = memoizeByValue(fn, {
weakResults?: boolean,
maxEntries?: number // default Infinity; 0 disables storage
})The key is valueKey([...args]). Property order, Set insertion order, and Map
insertion order do not matter; structurally equal rebuilt values hit. The
returned function exposes the underlying Map as .cache and .clear().
Hits move to the LRU tail. Invalid maxEntries values throw a RangeError.
With weakResults, a collected entry keeps occupying its LRU slot until aged
out or touched — a dead-but-recent entry can outlive an older live one.
Recency is about keys, not liveness; size the cap accordingly.
The valuehash domain is deliberately closed. Functions, symbols, unknown class instances, WeakMap, WeakSet, Promise, DataView, and other unsupported values throw. That refusal is part of the correctness contract.
Two inherited equivalences deserve emphasis:
- incidental sharing is erased:
[x, x]and[{}, {}]have the same value key whenxis empty; and - two differently classed iterables marked with
Symbol.for('unordered-collection')share a key when their multisets match.
The memoized function must be congruent with that equivalence. If its result depends on wrapper class or incidental sharing, use identity keys instead.
memoizeSmart(fn, options?)
const memoized = memoizeSmart(fn, {
weakResults?: boolean,
now?: () => number // deterministic clock seam; defaults to performance.now
})
memoized.disabled
memoized.stats
memoized.cache // IdentityCache while active; undefined after disable
memoized.clear()Smart uses exactly the identity cache above. Calls 1, 2, 4, 8, … close
log-spaced windows. Each sample times the full memo path and lookup/key slice;
one raw target invocation supplies current function cost. A sampled miss
provides that timing naturally. A sampled hit performs a counterfactual target
call and discards its result — and a probe exception propagates to the
caller like any target exception (a cache-hit call can therefore throw where
plain identity memoization would have returned the cached value; unreachable
for referentially transparent targets, which the contract already requires).
Consumers that count target invocations should use
stats.functionCalls/stats.probeCalls.
The timer measures synchronous invocation cost only. Do not use smart to judge async work: a returned Promise stops the timing slice before its work settles, and a sampled hit would start duplicate async work. The identity and value memoizers can cache Promises normally when that is the intended contract.
After four complete samples, a window can disable when:
expectedSaving = hitRate × functionCost − keyCost
expectedSaving < −0.20 × keyCostDisable is one-way. The cache reference is released, statistics retain the
final verdict, and later calls pay only the wrapper's branch and direct apply.
See DESIGN.md for the estimator and its bursty-workload risk.
maxEntries is intentionally ignored by identity and smart memoizers: weak
object edges are their eviction model, while primitive-only entries remain
until clear/disable.
this, name, and arity
All three wrappers pass this through when they execute the target, but
this is deliberately not a cache-key component. Calling the same wrapper
with equal arguments and different receivers returns the first cached result.
Bind receiver-sensitive functions before memoizing:
const memoized = memoizeByIdentity(method.bind(instance))The wrapper copies the target's name and declared length (arity).
Receipts
Measured 2026-07-11 with Node 24.13.1 on an Apple M5 Max (macOS 26.5.1). This was an interactive machine; background applications were not disabled. cyclebench interleaves candidates and verifies every result. Absolute nanosecond figures vary with machine load, and the smallest primitive cases sit near cyclebench's ~7 ns harness floor; ratios and larger batches are the useful signal.
| workload | raw | memotuple identity | moize | memoizee | lru-cache | |---|---:|---:|---:|---:|---:| | primitive hit | 6.30 ns* | 20.0 ns | 9.82 ns* | 30.2 ns | 12.8 ns* | | object-arg hit | 6.30 ns* | 23.8 ns | 13.6 ns* | 40.2 ns | 18.0 ns | | four-arg tuple hit | 26.6 ns | 87.5 ns | 41.8 ns | 93.6 ns | 435 ns | | 200 fresh object misses | 101 ns | 87.9 µs | 2.61 ms | 1.36 ms | 28.5 µs |
* within 2× of the harness floor. memotuple loses raw hit overhead to moize:
about 2.0× on the primitive hit, 1.75× on the object hit, and 2.1× on four
arguments. Last-call memoizers such as memoize-one can be faster still on their
single-entry contract. memotuple is paying for an arbitrary-size tuple trie,
SameValue correctness, and weak object edges; use it for that contract, not to
win a one-entry microbenchmark.
The workload memoization should lose — 256 unique arguments and a cheap integer mixer — shows the smart exit:
| raw | smart after verdict | identity cache | moize | |---:|---:|---:|---:| | 3.49 µs | 5.80 µs (1.66×) | 114 µs (32.6×) | 2.43 ms (697×) |
Run npm run bench to reproduce both timing tables and the correctness matrix.
Verification
npm test runs:
- primitive/object/mixed tuples, arities 0–5, SameValue edge cases, every
special result, exceptions, cache clearing, LRU, metadata, and
this; - live
FinalizationRegistryassertions in Vitest forks with--expose-gc: object arguments die while the identity cache stays live, weak results are recomputed, and cached undefined remains cached; - deterministic smart tests through the injected clock: profitable workloads stay enabled, zero-hit cheap workloads disable at the fourth sample, and passthrough output remains correct; and
- 10,000 seeded randomized calls per strategy, cross-checked against the raw function with isoequal while target invocation counts prove caching happened.
Install
npm install memotupleLicense
MIT © Xyra Sinclair
