npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

memotuple

v0.1.0

Published

Memoization with explicit identity, structural-value, and self-disabling key semantics.

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 observable

ESM 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-map trie, not a hash;
  • structural keys are valuehash canonical 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;
  • memoizeByValue stores canonical strings strongly and uses maxEntries for bounded LRU retention; and
  • weakResults: true stores object/function results through WeakRef. Collection turns the next lookup into a miss and recomputation. A dedicated sentinel represents a cached undefined, 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 when x is 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 × keyCost

Disable 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 FinalizationRegistry assertions 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 memotuple

License

MIT © Xyra Sinclair