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.
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))
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.
Map, Set, Date, RegExp, BigInt, Error, typed arrays, ArrayBuffer, DataView, undefined, NaN, Infinity, -Infinity and -0 come back with the type they had.
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.
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.
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.
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.
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.
| # | Tag | Payload |
|---|
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.
| Value | JSON | DSON |
|---|
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.
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.
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.
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.
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.
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.
No. They come back as plain objects that hold their own enumerable properties, without the class's prototype and methods.
They are dropped, as JSON.stringify drops them. Where an object has a toJSON() method, DSON writes what it returns, as JSON does.
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.
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.
Yes. DSON.isEqual compares DSON text, so the order of object keys and of Map and Set entries counts.
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.
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.
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.
| Operation | Median time | Peak RSS |
|---|---|---|
| Typed graph | ||
DSON.stringify | 3.8 ms | 156 MB |
DSON.parse | 2.7 ms | 135 MB |
DSON.isEqual | 6.8 ms | 150 MB |
DSON.clone | 1.5 ms | 116 MB |
| Plain graph | ||
JSON.stringify | 0.7 ms | 101 MB |
DSON.stringify | 2.0 ms | 151 MB |
JSON.parse | 0.3 ms | 102 MB |
DSON.parse | 2.6 ms | 144 MB |
| Operation | Median time | Peak RSS |
|---|---|---|
| Typed graph | ||
DSON.stringify | 4.4 ms | 64 MB |
DSON.parse | 3.9 ms | 63 MB |
DSON.isEqual | 11.7 ms | 85 MB |
DSON.clone | 3.0 ms | 27 MB |
| Plain graph | ||
JSON.stringify | 1.0 ms | 19 MB |
DSON.stringify | 3.9 ms | 53 MB |
JSON.parse | 0.7 ms | 20 MB |
DSON.parse | 4.5 ms | 56 MB |
| Operation | Median time | Peak RSS |
|---|---|---|
| Typed graph | ||
DSON.stringify | 4.0 ms | 197 MB |
DSON.parse | 3.3 ms | 157 MB |
DSON.isEqual | 5.0 ms | 330 MB |
DSON.clone | 2.5 ms | 133 MB |
| Plain graph | ||
JSON.stringify | 0.8 ms | 104 MB |
DSON.stringify | 2.2 ms | 182 MB |
JSON.parse | 0.6 ms | 112 MB |
DSON.parse | 2.5 ms | 158 MB |
| Operation | Median time | Peak RSS |
|---|---|---|
| Typed graph | ||
DSON.stringify | 9.3 ms | 129 MB |
DSON.parse | 5.5 ms | 106 MB |
DSON.isEqual | 13.5 ms | 187 MB |
DSON.clone | 1.9 ms | 73 MB |
| Plain graph | ||
JSON.stringify | 1.1 ms | 64 MB |
DSON.stringify | 4.4 ms | 146 MB |
JSON.parse | 0.8 ms | 60 MB |
DSON.parse | 4.9 ms | 110 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.
132 unit tests run on Node.js and on Bun; V8 measures the coverage on Node.js, and the build fails below 100 %.
Install the package, load it from a CDN, or hand one prompt to your coding agent.
bun add defuss-dson
npm install defuss-dson
Then, in Node.js, Bun or a bundled browser app:
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:
<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>
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
The source, the tests and the design notes are on GitHub.
MIT licensed