JSON that keeps your types

Maps come back as Maps Dates come back as Dates BigInt, NaN and undefined come back Cycles and shared objects come back All in 3.2 kB of gzipped JavaScript

JSON.stringify turns a Map into {}, a Date into an ISO string and NaN into null, and it throws on a BigInt or a cycle. DSON.stringify writes plain JSON text that records the type of every value, and DSON.parse rebuilds the original: same types, same cycles, same shared references.

Part of the defuss (opens in a new tab) ecosystem by Aron Homberg (kyr0) (opens in a new tab) · Open Source (MIT (opens in a new tab))

Same calls as JSON

DSON.stringify takes a value and returns a string; DSON.parse takes that string and returns the value. Neither has a replacer, reviver or indentation argument. The string is valid JSON, so it fits wherever JSON text goes: HTTP bodies, localStorage, postMessage, log lines.

Types that survive

Map, Set, Date, RegExp, BigInt, Error, typed arrays, ArrayBuffer, DataView, undefined, NaN, Infinity, -Infinity and -0 come back with the type they had.

Cycles and shared references

An object that refers to itself, or one object referenced from two places, is written once, and DSON.parse restores every reference to that one object. JSON.stringify throws on the cycle and writes the shared object twice.

Builds only its own types

DSON.parse builds only the types of the format and throws on any other type name, so a payload cannot make it construct Function, WebSocket or another global. A __proto__ key stays an ordinary property, as with JSON.parse. The parsed value is still untrusted data.

Deep equality and cloning

DSON.isEqual compares two values by their DSON text, so it handles cycles and tells a Map from an object with the same entries. DSON.clone deep-copies with the native structuredClone and falls back to DSON where a runtime lacks it.

Small, with no dependencies

3.2 kB of gzipped JavaScript and no dependencies, published as ESM and CommonJS with TypeScript types. CI tests the package and every JavaScript example on this page in Chromium, WebKit, Node.js and Bun on each push and pull request.

Playground

What DSON looks like

DSON text is a JSON array of [tag, payload] records. The tag names each value's type, and records refer to each other by index, so a cycle or a shared object costs one number. DSON.parse turns the records back into the same Maps, Sets, Dates, BigInts and cycles.

Your code is the body of an async function with DSON and console in scope; return the value to encode. It runs again each time you stop typing.

round trip: equal
JSON vs DSON

What comes back after a round trip

Each row sends one value through JSON.stringify and JSON.parse, then through DSON.stringify and DSON.parse, live in your browser. A cell shows what came back or the error thrown. Its badge reads kept when the result has the original type and content, and for a cycle or a shared reference also the original object identity; otherwise it reads lost.

ValueJSONDSON
How it works

A flat list of tagged records, written as JSON

DSON.stringify turns a value and everything it references into a list of [tag, payload] records, with the value itself as record 0, and writes that list with JSON.stringify. DSON.parse reads the list and rebuilds the value.

Round trip stringify on the way out, parse on the way back
Your valueMap · Date · cycles
serializeWalk onceone index per value
serializeTag[tag, payload] records
DSON textvalid JSON
Known tag?
deserializeRebuildcontainers first
TypeErrorunknown tag
Same valuetypes · cycles · shared refs
  1. DSON.stringify
  2. JSON.stringify
  3. DSON.parse
  4. yes
  5. no
  1. Step 1

    Walk once

    The encoder visits each value once and gives it a record index. When it meets the same value again, it writes that index instead of a second copy, so a cycle or a shared reference becomes a number.

  2. Step 2

    Tag

    A number names the kind of each record: 1 array, 2 object, 5 Map, 8 BigInt and so on. Typed arrays, ArrayBuffer and DataView use their constructor name instead, such as "Uint8Array", with their items as the payload.

  3. Step 3

    Write JSON

    JSON.stringify writes the record list as text. Any JSON parser can read that text, but only DSON.parse turns the records back into the original values.

  4. Step 4

    Rebuild

    DSON.parse creates each container before its members, so a member that points back to the container finds it. It builds only the types of the format and throws a TypeError on an unknown tag.

records.js
import { DSON } from "defuss-dson";

const node = { tags: new Set(["a"]) };
node.self = node;

console.log(DSON.stringify(node)); // [[2,[[1,2],[4,0]]],[0,"tags"],[6,[3]],[0,"a"],[0,"self"]]

Record 0 is the object (tag 2). Its payload holds one [key, value] pair of record indices per property. [1,2] maps the key "tags" (record 1) to the Set (record 2, tag 6), whose one member is record 3, "a". [4,0] maps the key "self" (record 4) back to record 0, the object itself. The format specification (opens in a new tab) lists every tag.

FAQ

Worth knowing

Do class instances keep their class?

No. They come back as plain objects that hold their own enumerable properties, without the class's prototype and methods.

What happens to functions and symbols?

They are dropped, as JSON.stringify drops them. Where an object has a toJSON() method, DSON writes what it returns, as JSON does.

Does a Date keep its time zone?

A Date holds an instant, not a time zone, so DSON stores its UTC text from toISOString(). The instant survives to the millisecond; an offset such as +02:00 in the text you built the date from does not. Store the zone in its own field when you need it.

What does an Error keep?

Its name and message, not its stack or other properties. Built-in classes such as TypeError come back as that class; a custom subclass comes back as an Error with the same name.

Does key order matter to DSON.isEqual?

Yes. DSON.isEqual compares DSON text, so the order of object keys and of Map and Set entries counts.

Can older versions read the text?

Yes, unless it holds NaN, Infinity, -Infinity or -0. Version 1.2 writes these with a new record tag, and readers before 1.2.0 throw on it instead of misreading the value.

How deeply can values nest?

DSON.parse reads any depth; its work grows with the length of the text. DSON.stringify and DSON.isEqual accept 100,000 levels and throw a RangeError beyond, which stops a getter that returns a new object on every read. Pass { maxDepth: 1_000_000 } as the last argument for deeper data, or Infinity to remove the limit.

Benchmarks

Speed, memory and test coverage

Two inputs of 2,000 users each. The typed graph holds what DSON adds: Maps, Sets, Dates, typed arrays, BigInts and a cycle. The plain graph holds the same data as JSON-safe values, so DSON and the runtime's own JSON work on equal input.

OperationMedian timePeak RSS
Typed graph
DSON.stringify3.8 ms156 MB
DSON.parse2.7 ms135 MB
DSON.isEqual6.8 ms150 MB
DSON.clone1.5 ms116 MB
Plain graph
JSON.stringify0.7 ms101 MB
DSON.stringify2.0 ms151 MB
JSON.parse0.3 ms102 MB
DSON.parse2.6 ms144 MB

Measured with make bench on 2026-11-01, Apple M5 Max (darwin arm64), against defuss-dson 1.2.0, in Chromium 153.0.8010.12, Bun 1.4.3, WebKit 26.6 and Node.js 26.11.1. Each time is the median of 20 samples, each sample long enough for the browsers' coarse timers. Peak RSS covers a fresh process that builds the inputs and runs the operation 21 times: in Node.js and Bun the process's own high-water mark, in the browsers the page's process, sampled while the operation runs. Building the inputs alone takes 96 MB and 99 MB in Chromium; 13 MB and 13 MB in Bun; 99 MB and 101 MB in WebKit; 58 MB and 57 MB in Node.js (typed and plain graph). The typed graph is 367,895 bytes of DSON; the plain graph is 257,812 bytes of JSON and 391,223 bytes of DSON.

Unit test coverage

100%
Lines
100%
Branches
100%
Functions
100%
Statements

132 unit tests run on Node.js and on Bun; V8 measures the coverage on Node.js, and the build fails below 100 %.

Use it

Add it to your own app

Install the package, load it from a CDN, or hand one prompt to your coding agent.

Package

bun add defuss-dson
npm install defuss-dson

Then, in Node.js, Bun or a bundled browser app:

session.js
import { DSON } from "defuss-dson";

const session = { user: "ada", roles: new Set(["admin"]), expires: new Date("2030-01-01T00:00:00.000Z") };
const text = DSON.stringify(session); // plain JSON text: store it, send it, log it
const back = DSON.parse(text);

console.log(back.roles.has("admin")); // true
console.log(back.expires.getUTCFullYear()); // 2030
console.log(DSON.isEqual(session, back)); // true

Without a bundler, straight from jsDelivr:

index.html
<script type="module">
  import { DSON } from "https://cdn.jsdelivr.net/npm/defuss-dson@1.2.0/dist/index.mjs";

  const text = DSON.stringify({ seen: new Set([1, 2]) });
  console.log(DSON.parse(text).seen.has(2)); // true
</script>

Vibe coding

Paste this prompt into your coding agent. It links the repository, so the agent works from the current README.

Use defuss-dson in this app wherever JSON loses types: https://github.com/kyr0/defuss-dson
Install the npm package defuss-dson and read its README first. Import { DSON } from "defuss-dson". Where this app both writes and reads the text, and the values hold Map, Set, Date, BigInt, typed arrays, undefined, NaN or cycles, replace JSON.stringify/JSON.parse with DSON.stringify/DSON.parse. Leave JSON that other programs read unchanged.
Use DSON.isEqual for deep comparison and DSON.clone for deep copies. Class instances come back as plain objects, and both ends need defuss-dson 1.2 or newer when values can be NaN, Infinity or -0.
send

Typed JSON for browsers, Node.js and Bun

The source, the tests and the design notes are on GitHub.

MIT licensed