valuehash
v0.1.0
Published
Exact canonical value keys for JavaScript, with an optional compact 32-bit hash.
Downloads
152
Maintainers
Readme
valuehash
Exact canonical value keys for JavaScript. valueKey(a) === valueKey(b)
means the supported values have the same structure; no digest collision is
hiding behind that answer. valueHash is the explicitly lossy 32-bit layer.
object-hash gives you a digest with collisions you cannot audit; valuehash
gives you an exact canonical key, and a digest only if you ask.
import { valueKey } from 'valuehash'
const cache = new Map<string, unknown>()
const query = { filters: new Set(['open', 'mine']), page: 1 }
cache.set(valueKey(query), await runQuery(query))
const rebuilt = { page: 1, filters: new Set(['mine', 'open']) }
cache.get(valueKey(rebuilt)) // the cached resultWhy this exists
Hash packages are useful digest functions, but a digest cannot provide an
exact structural key. Some also quietly erase distinctions before hashing.
These are live probes from npm run bench (Node 24.13.1):
| case | valueKey | object-hash | hash-it | node-object-hash | hash-sum |
|---|---|---|---|---|---|
| object key order | ✓ | ✓ | ✓ | ✓ | ✓ |
| '1', 1, 1n, [1] | ✓ | ✓ | ✓ | ✓ | ✓ |
| Set insertion order | ✓ | ✓ | ✓ | ✓ | ✓ |
| different Set contents | ✓ | ✓ | ✓ | ✓ | ✗ collision |
| different Map contents | ✓ | ✓ | ✓ | ✓ | ✗ collision |
| ordered self-cycle | ✓ | ✓ | ✓ | 💥 throws | ✓ |
| -0 / NaN (SameValue) | ✓ | ✗ | ✗ | ✗ | ✗ |
| cycle through Set | △ throws by design | ✓ | ✓ | 💥 throws | ✓ |
The last row is not a victory for implementations that return a digest: canonicalizing a cyclic graph across unordered edges is graph canonization. valuehash refuses that input instead of making an insertion-order-dependent promise. DESIGN.md gives the constructive collision proof and the exact boundary.
The golden property
For supported values, modulo the two caveats below, the intended contract is both directions:
isoEqual(a, b) // implies valueKey(a) === valueKey(b)
valueKey(a) === valueKey(b) // implies isoEqual(a, b)Primitives use SameValue: NaN equals NaN, while -0 differs from 0.
Objects ignore property insertion order, Sets and Maps ignore insertion order,
and sparse holes differ from stored undefined.
The first deliberate caveat is sharing. isoequal decides sharing-sensitive graph isomorphism; valuehash decides the coarser unfolding structure. A reference reached again outside the current ancestor path is serialized again:
const x = {}
valueKey([x, x]) === valueKey([{}, {}]) // true
isoEqual([x, x], [{}, {}]) // falseThat is necessary for a generally useful value key: incidental memoization or subtree reuse should not change it. Active cycles still use canonical path-relative back-references.
The second deliberate caveat is class erasure for declared-unordered
iterables: anything speaking the protocol is keyed as the multiset of its
iterated members, with its concrete class projected away — so a marked
Bag([2, 1]) and a Set([1, 2])-shaped twin from a different class get
the same key, while isoequal (which checks prototype identity first)
distinguishes them. This is the protocol's point — the key names the
contents, not the wrapper — but it means the reverse implication holds
only up to that projection. One more sharp edge of the protocol: member
iteration must be repeatable — a one-shot generator drains on the first
key and produces a different key next time. And independently of the
protocol, integer-typed TypedArrays are keyed by their underlying bytes,
which follow platform endianness (little-endian on effectively all modern
targets).
Supported values
The v0.1 domain is deliberately closed:
- string, number, bigint, boolean,
null, andundefined; - plain objects with
Object.prototypeor a null prototype, using enumerable string keys sorted lexicographically; - arrays by indexed slots, including exact sparse holes (custom properties are outside array identity);
- Set and Map as unordered structural collections, including enumerable string properties on the collection object;
- Date, RegExp, ArrayBuffer, TypedArrays, and boxed string/number/bigint/boolean;
- any iterable declaring the unordered-collection protocol below; and
- ordered cycles, plus cycles contained wholly inside one independently serialized unordered member.
Functions, symbols, WeakMap, WeakSet, Promise, DataView, detached buffers, unknown class instances, and unknown TypedArray subclasses throw. Symbol-keyed and non-enumerable properties are outside v0.1 structural identity. Built-in leaf objects use their logical value; ArrayBuffers use exact bytes and float TypedArrays normalize NaN payloads to SameValue semantics.
The unordered-collection protocol
valuehash honors the same package-neutral declaration as isoequal:
import { UNORDERED, valueKey } from 'valuehash'
class Bag<T> {
readonly [UNORDERED] = true
constructor(private items: T[]) {}
*[Symbol.iterator]() { yield* this.items }
}
valueKey(new Bag([1, 1, 2])) === valueKey(new Bag([2, 1, 1])) // trueThe logical identity of a declared collection is its iterated multiset;
multiplicity counts and concrete class fields do not. Libraries that cannot
carry the symbol can use createValueKey({ isUnorderedCollection }).
Worst case, honestly
Every unordered member is serialized independently and the resulting strings
are sorted. If that serialization reaches an ancestor outside the member, the
cycle crosses an unordered boundary and valuehash throws a clear TypeError.
A cheap exact key for that case would require full graph canonization;
isoequal can still decide equality
when no key is required.
Serialization is proportional to the emitted representation in ordinary use,
but that representation can be large: an exact key is linear-sized, and
repeated unshared unfoldings can repeat content. Use valueHash when a
fixed-width, probabilistic key is the right tradeoff. Sorting a collection of
m encoded members adds O(m log m) comparisons.
API
valueKey(value: unknown): string
valueHash(value: unknown): number
createValueKey(options?: {
isUnorderedCollection?: (value: object) => boolean
}): (value: unknown) => string
UNORDERED // === Symbol.for('unordered-collection')valueKey returns an ASCII-safe, self-delimiting string. It is the exact API.
valueHash applies standard 32-bit FNV-1a to that string and returns an
unsigned number; it can collide, like every fixed-width digest.
Verification
npm test runs focused type/collision/cycle cases plus 4,500 seeded random
golden-property checks. The generator covers shuffled Sets and Maps, sparse
arrays, ordered cycles, NaN, -0, TypedArrays, ArrayBuffers, Dates, RegExps,
boxed primitives, and null-prototype objects. Equal shuffled clones must share
a key; unequal tree values must not.
npm run bench uses
cyclebench to print the correctness
matrix above and interleaved timing medians:
| workload | valueKey | valueHash | object-hash | hash-it | node-object-hash | hash-sum | |---|---:|---:|---:|---:|---:|---:| | small POJO | 0.55µs | 0.68µs | 10.54µs | 0.53µs | 0.95µs | 1.57µs | | 1k-node tree | 300.57µs | 379.88µs | 3,883.08µs | 309.32µs | 323.07µs | 1,117.50µs | | 10k-element array | 213.46µs | 318.20µs | 1,071.13µs | 271.54µs | 717.65µs | 2,037.00µs | | Set-heavy value | 90.10µs | 103.57µs | 760.35µs | 128.14µs | 82.03µs | 0.23µs (wrong) |
Measured on Node 24.13.1, Apple M5 Max. This was an interactive machine; background applications were not disabled. hash-sum's apparent Set win is a wrong-result path: the correctness probe shows that it ignores Set contents.
License
MIT © Xyra Sinclair
