Skip to content

About

Type-preserving JSON for JavaScript: readable on the wire, safe on untrusted input

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

9 Commits

Folders and files

Repository files navigation

Serialize

Type-preserving JSON for JavaScript: readable on the wire, safe on untrusted input

npm | Wire format

JSON.stringify turns a Date into a string, a Map into {} and a BigInt into an exception. @opentf/serialize keeps them β€” plus typed arrays, Temporal, undefined, NaN/-0 and circular references β€” while plain data stays plain JSON.

import { parse, stringify } from "@opentf/serialize";

const text = stringify({ at: new Date(0), tags: new Set(["a"]), big: 10n });
// {"v":{"at":"@1","tags":"@2","big":"@3"},"$refs":{"1":{"_t":"Date","_v":"1970-01-01T00:00:00.000Z"},…}}

const value = parse(text, { maxLength: 1_000_000, maxDepth: 64 });
value.at.getFullYear(); // a live Date again

Note

Pre-1.0. The API can change between minor versions. The wire format is the one obj-diff 0.18 writes, byte for byte.

Why Serialize

Need How Serialize meets it
πŸ‘€ Readable on the wire Plain data stays as it is; only special values become "@n" tokens, so a network panel still shows your object.
πŸ”’ Safe on hostile input __proto__ keys become own properties, never prototypes; every $refs entry is shape-checked; malformed input is an error, never undefined.
🚧 Bounded work Opt-in maxLength and maxDepth stop oversized or deeply nested bodies before they cost a stack overflow.
🏷️ Errors you can branch on One SerializeError class with a code β€” map INPUT_TOO_LARGE to 413 and the rest to 400.
πŸ“¦ No dependencies One small ESM + CJS package with its own .d.ts; runs anywhere JSON does.

Compared with other libraries

@opentf/serialize superjson devalue seroval (JSON mode) @ungap/structured-clone
Wire shape Your JSON + $refs table Your JSON + meta paths Flattened slot array Node tree Flattened records
Plain data stays readable Yes Yes No No No
Own __proto__ key Kept as own key Rejected (throws) Rejected (throws) Kept as own key Kept as own key
Wrong-shape input Throws Returns undefined Throws Throws Throws
Depth limit maxDepth (opt-in) None None depthLimit (1000) None
Input size limit maxLength (opt-in) None None None None
Cycles through Map/Set Yes Yes Yes Yes Yes
Custom classes No registerClass reducers / revivers Plugins No
Runtime dependencies 0 1 0 0 0

Serialize trades a little speed and size for a format you can read and a parser you can point at a request body. When you need custom classes, use seroval or devalue.

Measured

Measured 2026-11-04 on ES-Runtime/0.46.0 (Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz, 12 threads) against superjson 2.2.6, devalue 6.0.3, seroval 1.7.7, @ungap/structured-clone 1.4.0.

Round-trip fidelity

Each value goes through stringify then parse and must come back with the same type, value, keys and identity. βœ… exact Β· ❌ different value or type Β· πŸ’₯ throws.

Value @opentf/serialize superjson devalue seroval @ungap/structured-clone
Plain nested object βœ… βœ… βœ… βœ… βœ…
Date βœ… βœ… βœ… βœ… βœ…
Invalid Date βœ… ❌ βœ… βœ… βœ…
RegExp βœ… βœ… βœ… βœ… βœ…
Map βœ… βœ… βœ… βœ… βœ…
Set βœ… βœ… βœ… βœ… βœ…
BigInt βœ… βœ… βœ… βœ… βœ…
Int8Array βœ… βœ… βœ… βœ… βœ…
Float64Array [NaN, -0, ∞] βœ… ❌ βœ… βœ… ❌
BigInt64Array βœ… πŸ’₯ βœ… βœ… πŸ’₯
ArrayBuffer βœ… ❌ βœ… βœ… βœ…
DataView βœ… ❌ βœ… βœ… βœ…
Error + custom prop βœ… ❌ πŸ’₯ βœ… ❌
Boxed Number βœ… ❌ βœ… βœ… βœ…
Boxed String βœ… ❌ βœ… βœ… βœ…
undefined βœ… βœ… βœ… βœ… βœ…
NaN βœ… βœ… βœ… βœ… ❌
-0 βœ… βœ… βœ… βœ… βœ…
Infinity βœ… βœ… βœ… βœ… ❌
URL βœ… βœ… βœ… πŸ’₯ ❌
Temporal.PlainDate βœ… ❌ βœ… βœ… ❌
Temporal.ZonedDateTime βœ… ❌ βœ… βœ… ❌
Shared reference βœ… βœ… βœ… βœ… βœ…
Circular object βœ… βœ… βœ… βœ… βœ…
Circular array βœ… βœ… βœ… βœ… βœ…
Cycle through a Map βœ… βœ… βœ… βœ… βœ…
Sparse array βœ… ❌ βœ… βœ… ❌
Own proto key βœ… πŸ’₯ πŸ’₯ βœ… βœ…
Score 28/28 16/28 26/28 27/28 19/28

Malformed input

What parse does with valid JSON that is not its own format. Throwing is the safe answer; a quiet undefined reaches application code as if it were data.

parse(...) input @opentf/serialize superjson devalue seroval @ungap/structured-clone
Empty object {} throws ⚠️ returns undefined throws throws throws
Foreign object {"x":1} throws ⚠️ returns undefined throws throws throws
Bare number 42 throws ⚠️ returns undefined throws throws throws

Wire size

UTF-8 bytes of the output; smallest in bold.

Value @opentf/serialize superjson devalue seroval @ungap/structured-clone
Flat object 41 44 43 126 83
Mixed native types 171 153 108 268 146
Typed array (8) 71 96 58 171 55
Nested record 192 192 133 270 182
Tricky numbers 132 115 24 113 74
Circular graph 67 83 25 104 49

Speed

Typed record (ops/sec, higher is better)

Library stringify parse
@opentf/serialize 135,894 159,406
superjson 53,343 165,938
devalue 185,374 241,824
seroval 199,268 111,388
@ungap/structured-clone 163,472 156,489

1,000 plain rows (ops/sec, higher is better)

Library stringify parse
@opentf/serialize 1,443 1,585
superjson 212 3,145
devalue 577 1,085
seroval 744 472
@ungap/structured-clone 621 545
JSON 6,078 3,122

1,000 typed rows (ops/sec, higher is better)

Library stringify parse
@opentf/serialize 397 387
superjson 108 317
devalue 409 692
seroval 503 419
@ungap/structured-clone 370 474

Built for

Project Use
obj-diff Its diff serialize / deserialize and value stringify / parse, extracted here unchanged in format. obj-diff's own test suite passes against a thin wrapper over encode / decode.
AFW server functions The planned body and result encoding (docs/SERVER_FUNCTIONS.md Β§3.2), which needs a decoder that is safe for untrusted bodies.

Install

pnpm add @opentf/serialize
npm install @opentf/serialize   # or: bun add @opentf/serialize

Supported values

Kind Values
Primitives string, number, boolean, null, undefined, bigint, NaN, Β±Infinity, -0
Built-ins Date (incl. invalid), RegExp, URL, Map, Set, boxed Number / String / Boolean
Errors Error and its standard subclasses, with name, message and own properties
Binary Every typed array, ArrayBuffer, DataView β€” byte-exact, as base64
Temporal Any Temporal.* value; decoding needs globalThis.Temporal
Graphs Cycles and shared identity through objects, arrays, Map, Set and Error; any value used twice decodes to one object
Sparse arrays Holes stay holes, distinct from undefined and null

Symbols, functions and class instances throw.

Wire format

stringify writes an envelope {"v": <value>, "$refs"?: {...}}:

Piece Rule
Plain data Written as ordinary JSON. User keys named _t / _v are just data.
Special values Replaced by a token "@n"; $refs["n"] holds { "_t": <type>, "_v": <payload> }.
Strings starting with @ Escaped with one more @, so they never read as tokens.
Shared or cyclic values Written once in $refs (plain containers as an obj / arr entry) and referenced by token everywhere; single-use plain containers stay inline.
Array holes Every hole is a token for one shared {"_t":"hole"} entry.
No special values No $refs key at all: {"v":{"a":1}}.

Untrusted input

import { parse, SerializeError } from "@opentf/serialize";

try {
  return parse(body, { maxLength: 1_000_000, maxDepth: 64 });
} catch (err) {
  if (err instanceof SerializeError) return new Response(null, { status: err.code === "INPUT_TOO_LARGE" ? 413 : 400 });
  throw err;
}
Option Effect Default
maxLength Longest input, in UTF-16 code units (text.length), checked before JSON.parse Unlimited
maxDepth Deepest container nesting β€” arrays, objects, Map, Set, Error props, hoisted ones included. [[1]] is 2 Unlimited

stringify(value, { maxDepth }) takes the same depth limit.

Errors

Every failure is a SerializeError (a subclass of Error) whose code is one of:

code Meaning
INVALID_INPUT parse was given something other than a string
INVALID_JSON The text is not JSON (cause holds the SyntaxError)
INPUT_TOO_LARGE Longer than maxLength
INVALID_ENVELOPE Not {"v": ...}, or $refs is not an object
MAX_DEPTH Nests deeper than maxDepth
MISSING_REF A "@n" token with no $refs entry
UNKNOWN_TYPE An unrecognised _t tag
INVALID_PAYLOAD An entry's _v is the wrong shape for its type
CIRCULAR_REF A forged entry that refers to itself where no cycle is possible (e.g. a boxed Number)
UNSUPPORTED_VALUE A symbol, function or class instance given to stringify
TEMPORAL_UNAVAILABLE Temporal data, but no globalThis.Temporal
INVALID_OPTION A limit that is not a non-negative integer or Infinity

API

Export Purpose
stringify(value, options?) Any value β†’ JSON text
parse(text, options?) JSON text β†’ value; validates everything it reads
encode(roots, options?) Low level: encode several values that share one $refs table, to embed in your own JSON
decode(values, refs, options?) Low level: the inverse of encode
SerializeError The error class; see Errors

encode / decode are how obj-diff writes each diff op as {type, path, value, $refs}:

const { values: [path, value], refs } = encode([op.path, op.value]);
const wire = JSON.stringify({ type: op.type, path, value, $refs: refs });

const raw = JSON.parse(wire);
const [p, v] = decode([raw.path, raw.value], raw.$refs, { maxDepth: 64 });

Develop

pnpm install
tsr ci            # build, tests on src/ and dist/, typecheck, lint, format check
tsr test          # tests against src/
tsr test:dist     # build, then the tests against dist/
tsr bench:readme  # run the benchmarks and regenerate the Measured section above

Tests, builds and benchmarks run on esdev; tasks are defined in tasks.toml and run with tsr.

License

MIT β€” see LICENSE.

About

Type-preserving JSON for JavaScript: readable on the wire, safe on untrusted input

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages