tuple-keyed-map
v0.1.0
Published
A Map keyed by tuples whose entries are garbage-collected when their object components die — WeakMap edges for objects, Map edges for primitives, and a single-walk getOrInsert.
Maintainers
Readme
tuple-keyed-map
A Map keyed by tuples whose entries are garbage-collected when their
object components die. Object components travel over WeakMap edges,
primitive components over Map edges; an entry keyed by [obj, "config"]
lives exactly as long as obj. No eviction policy, no registry, no leak.
(The GC story is about object components: a tuple of only primitives has
nothing to die, so it is retained until delete() or clear() — exactly
like a plain Map entry.)
import { TupleMap } from 'tuple-keyed-map'
const cache = new TupleMap<[object, string], Result>()
cache.set([user, 'profile'], result)
cache.get([user, 'profile']) // result — fresh array, same components
cache.getOrInsertComputed([user, 'avatar'], render) // one walk, TC39 upsert shape
// drop `user` and the entry, its value, and its subtree become garbageZero runtime dependencies. ESM and TypeScript declarations included.
Why this exists
The established tuple-key package, many-keys-map, is a trie of ordinary
Maps: it holds strong references to every object component forever.
Used as a cache keyed by request objects, DOM nodes, or model instances,
that is a leak by construction. This package is the complement: weak edges,
GC-transparent entries — and, it turns out, considerably faster.
The price is stated up front: WeakMaps cannot be enumerated, so neither
can this structure — no size, no iteration. That is a theorem, not an
implementation gap (see DESIGN.md): a structure that can
list its entries is a structure that retains them. Pick per use:
| need | use |
|---|---|
| iteration, size, strong retention is fine | many-keys-map |
| entries that die with their key objects | tuple-keyed-map |
Receipts
Measured on Node 24.13.1, Apple Silicon, 2026-07-11.
Retention (npm run probe — drop 20,000 key objects while their
entries are in the map, then GC):
| | key objects collected | heap retained |
|---|---:|---:|
| tuple-keyed-map | 20,000 / 20,000 | 0.5 MiB |
| many-keys-map | 0 / 20,000 | 19.0 MiB |
Speed (npm run bench — cyclebench interleaves the candidates and
cross-checks that both maps returned identical lookup sums; 1000 operations
per call):
| workload | tuple-keyed-map | many-keys-map | ratio |
|---|---:|---:|---:|
| get hit, [string, int, int] | 48.6µs | 201µs | 4.1× |
| get hit, [object, string] | 25.0µs | 243µs | 9.7× |
| get miss | 9.6µs | 175µs | 18.2× |
| set 1000 fresh tuples | 87.2µs | 233µs | 2.7× |
Ratios are the signal; absolutes vary with machine load. Reproduce both tables from this repo.
API
new TupleMap<K extends readonly unknown[], V>()
map.get(key) // V | undefined
map.has(key) // boolean — distinguishes stored undefined
map.set(key, value) // this, chainable
map.delete(key) // boolean — prunes empty trie nodes
map.clear() // void
map.getOrInsert(key, defaultValue) // V — one walk; TC39 upsert shape
map.getOrInsertComputed(key, compute) // V — compute runs only on a missSemantics worth knowing:
- Component equality is exactly
Map/WeakMapkey equality: SameValueZero for primitives (NaNmatchesNaN,-0matches0), identity for objects and functions. - Arity discriminates:
[1],[1, 2], and[1, undefined]are three different keys. The empty tuple[]is a valid key. - Symbols are kept on strong edges (registered symbols cannot be weak keys; treating all symbols alike keeps the contract predictable).
deleteprunes empty interior nodes bottom-up, so churning through distinct primitive tuples does not accrete shells. One bounded exception: a node that ever grew a weak edge cannot be proven empty (WeakMaps hide their population), so it is kept until the objects above it die orclear()runs.
Verification
Nineteen tests: the full Map contract (SameValueZero components, arity
discrimination, stored-undefined vs absent), a 20,000-step randomized
model-equivalence run against a serialized-key reference Map, white-box
pruning checks, and — the reason the package exists — live GC assertions
under --expose-gc: key objects and values are finalized while their
entry is still in the map, and a strong-Map control in the same harness
is not.
Install
npm install tuple-keyed-mapMIT © Xyra Sinclair
