Type-preserving JSON for JavaScript: readable on the wire, safe on untrusted input
An Open Tech Foundation project
JSON.stringifyturns aDateinto a string, aMapinto{}and aBigIntinto an exception.@opentf/serializekeeps them β plus typed arrays,Temporal,undefined,NaN/-0and 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 againNote
Pre-1.0. The API can change between minor versions. The wire format is the one obj-diff 0.18 writes, byte for byte.
| 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. |
| @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 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.
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 |
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 | undefined |
throws | throws | throws |
Foreign object {"x":1} |
throws | undefined |
throws | throws | throws |
Bare number 42 |
throws | undefined |
throws | throws | throws |
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 |
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 |
| 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. |
pnpm add @opentf/serializenpm install @opentf/serialize # or: bun add @opentf/serialize| 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.
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}}. |
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.
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 |
| 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 });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 aboveTests, builds and benchmarks run on esdev; tasks are
defined in tasks.toml and run with tsr.
MIT β see LICENSE.