@opentf/serialize
v0.1.0
Published
Type-preserving JavaScript serialization: Date, Map, Set, typed arrays, BigInt, Temporal, cycles and more, over plain JSON.
Maintainers
Readme
Serialize
Type-preserving JSON for JavaScript: readable on the wire, safe on untrusted input
npm | JSR | Wire format
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 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/serializenpm install @opentf/serialize # or: bun add, deno add jsr:@opentf/serializeSupported 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 aboveTests, builds and benchmarks run on esdev; tasks are
defined in tasks.toml and run with tsr.
License
MIT — see LICENSE.
