json-marshal
TypeScript icon, indicating that this package has built-in type declarations

0.0.1 • Public • Published

JSON Marshal

JSON serializer that can stringify and parse any data structure.

npm install --save-prod json-marshal
import { stringify, parse, SerializationOptions } from 'json-marshal';
import regexpAdapter from 'json-marshal/adapter/regexp';

const options: SerializationOptions = {
  adapters: [regexpAdapter()]
};

const json = serialize({ hello: /Old/g }, options);
// ⮕ '{"hello":[7,"Old","g"]}'

parse(json, options);
// ⮕ { hello: /Old/g }

Overview

Circular references:

const gunslinger = {};

// Circular reference
gunslinger.bill = gunslinger;

serialize(hello);
// ⮕ '{"bill":[0,0]}'

Out-of-the-box undefined, NaN, Infinity, and BigInt can be stringified:

stringify(undefined);
// ⮕ '[1]'

stringify(1_000_000n);
// ⮕ '[5,"1000000"]'

By default, object properties with undefined values aren't serialized. Override this with undefinedPropertyValuesPreserved option:

const gunslinger = { hello: undefined };

stringify(gunslinger);
// ⮕ '{}'

stringify(gunslinger, { undefinedPropertyValuesPreserved: true });
// ⮕ '{"hello":[1]}'

All objects are always serialized only once and then referenced if needed, so no excessive serialization is performed. This results in a smaller output and faster serialization/deserialization times in comparison to peers:

import { stringify } from 'json-marshal';

const gunslinger = { hello: 'bill' };

const gang = [gunslinger, gunslinger, gunslinger];

JSON.stringify(gang);
// ⮕ '[{"hello":"bill"},{"hello":"bill"},{"hello":"bill"}]'

stringify(gang);
// ⮕ [{"hello":"bill"},[0,1],[0,1]]

By default, object property keys appear in the serialized string in the same order they were added to the object:

stringify({ kill: 'Bill', hello: 'Greg' });
// ⮕ '{"kill":"Bill","hello":"Greg"}'

Provide stable option to sort keys in alphabetical order:

stringify({ kill: 'Bill', hello: 'Greg' }, { stable: true });
// ⮕ '{"hello":"Greg","kill":"Bill"}'

Serialization adapters

By default, only enumerable object properties are stringified:

stringify({ hello: 'Bob' });
// ⮕ '{"hello":"Bob"}'

stringify(new ArrayBuffer(10));
// ⮕ '{}'

Provide a serialization adapter that supports the required object type to enhance serialization:

import { stringify } from 'json-marshal';
import arrayBufferAdapter from 'json-marshal/adapter/array-buffer';

const json = stringify(new ArrayBuffer(10), { adapters: [arrayBufferAdapter()] });
// ⮕ '[23,"AAAAAAAAAAAAAA=="]'

When deserializing, the same adapters must be provided, or an error would be thrown:

import { parse } from 'json-marshal';

parse(json);
// ❌ Error: Unrecognized tag: 23

parse(json, { adapters: [arrayBufferAdapter()] });
// ⮕ ArrayBuffer(10)

Built-in adapters

Built-in adapters can be imported as json-marshal/adapter/*:

import arrayBufferAdapter from 'json-marshal/adapter/array-buffer';

stringify(new ArrayBuffer(10), { adapters: [arrayBufferAdapter()] });
array-buffer

Serializes typed arrays, DataView and ArrayBuffer instances as Base64-encoded string.

date

Serializes Date instances.

error

Serializes DOMException, Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, and URIError.

map

Serializes Map instances. If stable option is provided, Map keys are sorted in alphabetical order.

regexp

Serializes RegExp instances.

set

Serializes Set instances. If stable option is provided, Set items are sorted in alphabetical order.

Authoring a serialization adapter

You can create custom adapters for your object types. For example, let's create a Date adapter:

import { SerializationAdapter } from 'json-marshal';

const DATE_TAG = 222;

const dateAdapter: SerializationAdapter = {

  getTag: (value, options) =>
    value instanceof Date ? DATE_TAG : undefined,

  getPayload: (tag, value, options) =>
    value.getTime(),

  getValue: (tag, dehydratedPayload, options) =>
    tag === DATE_TAG ? new Date(dehydratedPayload) : undefined,
};

During serialization, each object is passed to the getTag method. If must return the unique tag (a positive integer) of the value type, or undefined if the adapter doesn't recognize the type of the given value.

Then the getPayload method is used to convert the value into a serializable form. The payload returned from the getPayload method is stringified. During stringification, payloads are dehydrated: circular references and reused references are replaced with tags. For example, the tag that references the second object during the depth-first traversal looks kile this: [0,1].

During deserialization, getValue method receives the dehydrated payload along with its tag and must return a deserialized value, or undefined if deserialization isn't supported for the given tag. If getValue returns a non-undefined value, a hydrateValue method is called. It receives a value created by getValue and the hydrated payload that can be used to enrich the original value.

For example, if you're deserializing a Set instance, then new Set() must be returned from the getValue, and in hydrateValue items from the hydrated payload should be added to the set. This approach allows to hydrate cyclic references in an arbitrary object. If value hydration isn't required (like in the example with Date serialization), hydrateValue method can be omitted.

Let's use our dateAdapter:

import { stringify, parse } from 'json-marshal';

const json = stringify({ today: new Date() }, { adapters: [dateAdapter] });
// ⮕ '{"today":[222,1716291110044]}'

parse(json, { adapters: [dateAdapter] });
// ⮕ { today: Date }

Return DISCARDED from the getPayload method to exclude the provided value from being stringified. For example, lets write an adapter that serializes runtime-wide symbols and discards local symbols.

import { DISCARDED, stringify, SerializationAdapter } from 'json-marshal';

const SYMBOL_TAG = 333;

const symbolAdapter: SerializationAdapter = {

  getTag: (value, options) =>
    typeof value === 'symbol' ? SYMBOL_TAG : undefined,

  // 🟡 Only runtime-wide symbols are serialized
  getPayload: (tag, value, options) =>
    Symbol.for(value.description) === value ? value.description : DISCARDED,

  getValue: (tag, dehydratedPayload, options) =>
    tag === SYMBOL_TAG ? Symbol.for(dehydratedPayload) : undefined,
};

Runtime-wide symbols can now be serialized with symbolAdapter:

stringify([Symbol.for('hello')], { adapters: [symbolAdapter] });
// ⮕ '[[333,"hello"]]'

// 🟡 Local symbol is discarded in serialized data
stringify([Symbol('goodbye')], { adapters: [symbolAdapter] });
// ⮕ '[]'

Performance

The chart below showcases the performance comparison of JSON Marshal and its peers, in terms of thousands of operations per second (greater is better).

Performance comparison chart

Tests were conducted using TooFast on Apple M1 with Node.js v20.4.0.

To reproduce the performance test suite results, clone this repo and run:

npm ci
npm run build
npm run perf

Package Sidebar

Install

npm i json-marshal

Weekly Downloads

2

Version

0.0.1

License

MIT

Unpacked Size

57.3 kB

Total Files

34

Last publish

Collaborators

  • smikhalevski