Skip to content

feat(serialize, hash): add serialize to es-toolkit/util and hash to es-toolkit/util/hash - #2067

Merged
raon0211 merged 9 commits into
mainfrom
feat/serialize
Aug 30, 2026
Merged

feat(serialize, hash): add serialize to es-toolkit/util and hash to es-toolkit/util/hash#2067
raon0211 merged 9 commits into
mainfrom
feat/serialize

Conversation

@raon0211

@raon0211 raon0211 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Overview

Adds two related utilities:

  • serialize (es-toolkit/util): serializes any value into a stable string.
  • hash (es-toolkit/util/hash): SHA-256 + Base64URL hash of the serialized value — a stable 43-character identifier for cache keys and change detection.
import { serialize } from 'es-toolkit/util';
import { hash } from 'es-toolkit/util/hash';

serialize({ b: 2, a: 1 }); // "{'a':1,'b':2}"
hash({ b: 2, a: 1 }) === hash({ a: 1, b: 2 }); // true
hash([1, 2, 3]); // "phXuruId5Red4IDejDBSyNqQEThAa6ccOMAyhF99VPQ"

serialize spec

  • Stable output: plain object keys, Map keys, and Set values are sorted (code-unit order, locale-independent), so structurally equal values always serialize identically regardless of insertion order.
  • Quoted strings everywhere: every string embedded in the output is quoted — values, keys, and payloads (Date('...'), Symbol('a'), Error(Name: 'message')). String keys are always quoted ({'a':1}, Map{'a':1}), so a string key never collides with a number/boolean/null key (Map{'123':1} vs Map{123:1}) and keys containing : or , cannot be confused with the surrounding structure.
  • Primitives: 'str' (single quotes), -00, 123n, Symbol('desc') (Symbol() without a description), NaN/Infinity as-is.
  • Builtins: Date('ISO') (invalid dates → Date(null)), RegExp(/.../flags), Set[...], Map{'k':v}, all typed arrays (BigInt64Array with n suffixes), ArrayBuffer[bytes], Error(Name: 'message').
  • Class instances: Name{k:v}, honoring toJSON(); objects with entries() (FormData, URLSearchParams, Headers) as Tag{k:v}.
  • Circular references: #ref{n} back-references by visit order, with memoization for repeated references.
  • Unsupported objects (Promise, WeakMap, Blob, DataView, boxed primitives, generators): throw TypeError.
  • Functions: name:source with newlines collapsed; native functions as name:[native].

Dispatch uses fast predicates (Array.isArray, isPlainObject, instanceof-based es-toolkit predicates) instead of Object.prototype.toString string dispatch. Not designed for security purposes: strings and keys are not escaped.

hash design

  • Platform-conditional entrypoint (node/default conditions):
    • Node.js: one-shot crypto.hash('sha256', data, 'base64url') — requires Node.js 20.12+ (browser-support docs updated from 18+).
    • Browsers/edge: a pure JS SHA-256 (FIPS 180-4) with byte-identical output, verified against node:crypto with fixed vectors, padding block boundaries, multi-byte UTF-8, lone surrogates, 1MB input, and fast-check fuzzing.
  • Opt-in only: hash is reachable only via the explicit es-toolkit/util/hash subpath, so it never adds to bundles of the main entrypoints. Enforced in two layers:
    • ESLint: no-restricted-imports forbids any src/** file (outside src/util/hash) from importing it.
    • check-dist: the packed-tarball test now verifies no dist file outside dist/util/hash references it, and that loading es-toolkit / es-toolkit/util at runtime loads no module under dist/util/hash. This runs in the existing yarn test step of the Release workflow, gating every publish.
  • JSR: jsr.json exports ./util/hash to the node implementation (JSR has no conditional exports; crypto.hash verified working on Deno 2.x).
  • Legacy resolvers that ignore exports get a util/hash.js shim (same pattern as fp/iterator).

Benchmarks

vitest bench on Node.js 24 (Apple Silicon):

payload es-toolkit (node) es-toolkit (browser impl) ohash object-hash
small object (3 keys) 1.00x 3.98x slower 1.03x slower 14.3x slower
medium object (100 keys) 1.00x 1.50x slower 1.29x slower 19.6x slower
large array (1,000 objects) 1.00x 1.50x slower 1.17x slower 16.2x slower
long string (~10KB) 1.00x 9.9x slower 1.01x slower 2.0x slower

serialize alone vs the equivalent in ohash: 1.19x–1.34x faster across the same payloads. Benchmark files are included under benchmarks/performance/.

Structure

src/util/serialize/          # public: serialize (es-toolkit/util)
├── serialize.ts             # typeof dispatch
├── serializeObject.ts       # object dispatch + circular refs
├── serializePlainObject.ts / serializeString.ts / serializeNumber.ts
├── serializeBigInt.ts / serializeSymbol.ts / serializeFunction.ts
└── compareValues.ts

src/util/hash/               # public: hash (es-toolkit/util/hash only)
├── node.ts                  # node condition — native crypto.hash
├── browser.ts               # default condition — pure JS SHA-256
└── sha256.ts

Every module has a colocated .spec.ts. Docs added in all 4 languages for both functions.

Checklist

  • Implementation + tests (full suite: 712 files, 4,985 passed)
  • yarn lint, tsc --noEmit clean
  • yarn build verified: conditional dist outputs, isolation, runtime parity of both implementations
  • Benchmarks vs ohash / object-hash with results above
  • Docs: en / ko / ja / zh_hans (serialize, hash, browser-support)

@raon0211
raon0211 requested a review from dayongkr as a code owner August 28, 2026 07:37
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
es-toolkit Ready Ready Preview Aug 30, 2026 11:30pm

Request Review

@raon0211 raon0211 changed the title feat(serialize): add serialize to es-toolkit/util feat(serialize, hash): add serialize to es-toolkit/util and hash to es-toolkit/util/hash Aug 30, 2026
deno publish type-checks node:crypto imports against @types/node, which
is unavailable under Yarn PnP. Deno users can reach the subpath via
npm:es-toolkit/util/hash, which resolves the conditional exports.
String keys in objects, Maps, and entries() collections are now always
quoted, so a string key can no longer collide with a number, boolean,
null, or symbol key (Map{'123':1} vs Map{123:1}), and keys containing
':' or ',' can no longer be confused with the surrounding structure.
The internal noQuotes flag is removed.
…tput

Every string embedded in the output is now consistently quoted:
Date('1970-01-01T00:00:00.000Z'), Symbol('a'), Error(TypeError: 'boom').
This distinguishes Symbol() from Symbol('') and keeps the Error
name/message boundary unambiguous. Invalid dates stay Date(null),
since null is not a string.
@raon0211
raon0211 merged commit 6380c75 into main Aug 30, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant