diff --git a/README.md b/README.md index 123e9c7..94944e3 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,49 @@ const json = `{ const data = devalue.unflatten(JSON.parse(json).data); ``` +## Custom types + +You can serialize and serialize custom types by passing a second argument to `stringify` containing an object of types and their _reducers_, and a second argument to `parse` or `unflatten` containing an object of types and their _revivers_: + +```js +class Vector { + constructor(x, y) { + this.x = x; + this.y = y; + } + + magnitude() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } +} + +const stringified = devalue.stringify(new Vector(30, 40), { + Vector: (value) => value instanceof Vector && [value.x, value.y] +}); + +console.log(stringified); // [["Vector",1],[2,3],30,40] + +const vector = devalue.parse(stringified, { + Vector: ([x, y]) => new Vector(x, y) +}); + +console.log(vector.magnitude()); // 50 +``` + +If a function passed to `stringify` returns a truthy value, it's treated as a match. + +You can also use custom types with `uneval` by specifying a custom replacer: + +```js +devalue.uneval(vector, (value, uneval) => { + if (value instanceof Vector) { + return `new Vector(${value.x},${value.y})`; + } +}); // `new Vector(30,40)` +``` + +Note that any variables referenced in the resulting JavaScript (like `Vector` in the example above) must be in scope when it runs. + ## Error handling If `uneval` or `stringify` encounters a function or a non-POJO, it will throw an error. You can find where in the input data the offending value lives by inspecting `error.path`: diff --git a/src/parse.js b/src/parse.js index 890bec3..54cdeba 100644 --- a/src/parse.js +++ b/src/parse.js @@ -10,16 +10,18 @@ import { /** * Revive a value serialized with `devalue.stringify` * @param {string} serialized + * @param {Record any>} [revivers] */ -export function parse(serialized) { - return unflatten(JSON.parse(serialized)); +export function parse(serialized, revivers) { + return unflatten(JSON.parse(serialized), revivers); } /** - * Revive a value flattened with `devalue.flatten` + * Revive a value flattened with `devalue.stringify` * @param {number | any[]} parsed + * @param {Record any>} [revivers] */ -export function unflatten(parsed) { +export function unflatten(parsed, revivers) { if (typeof parsed === 'number') return hydrate(parsed, true); if (!Array.isArray(parsed) || parsed.length === 0) { @@ -30,7 +32,10 @@ export function unflatten(parsed) { const hydrated = Array(values.length); - /** @param {number} index */ + /** + * @param {number} index + * @returns {any} + */ function hydrate(index, standalone = false) { if (index === UNDEFINED) return undefined; if (index === NAN) return NaN; @@ -50,6 +55,11 @@ export function unflatten(parsed) { if (typeof value[0] === 'string') { const type = value[0]; + const reviver = revivers?.[type]; + if (reviver) { + return (hydrated[index] = reviver(hydrate(value[1]))); + } + switch (type) { case 'Date': hydrated[index] = new Date(value[1]); @@ -90,6 +100,9 @@ export function unflatten(parsed) { obj[value[i]] = hydrate(value[i + 1]); } break; + + default: + throw new Error(`Unknown type ${type}`); } } else { const array = new Array(value.length); diff --git a/src/stringify.js b/src/stringify.js index 13adb5b..8ce427e 100644 --- a/src/stringify.js +++ b/src/stringify.js @@ -17,14 +17,21 @@ import { /** * Turn a value into a JSON string that can be parsed with `devalue.parse` * @param {any} value + * @param {Record any>} [reducers] */ -export function stringify(value) { +export function stringify(value, reducers) { /** @type {any[]} */ const stringified = []; /** @type {Map} */ const indexes = new Map(); + /** @type {Array<{ key: string, fn: (value: any) => any }>} */ + const custom = []; + for (const key in reducers) { + custom.push({ key, fn: reducers[key] }); + } + /** @type {string[]} */ const keys = []; @@ -47,6 +54,14 @@ export function stringify(value) { const index = p++; indexes.set(thing, index); + for (const { key, fn } of custom) { + const value = fn(thing); + if (value) { + stringified[index] = `["${key}",${flatten(value)}]`; + return index; + } + } + let str = ''; if (is_primitive(thing)) { diff --git a/src/uneval.js b/src/uneval.js index 305fb6a..79f35a3 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -15,13 +15,16 @@ const reserved = /** * Turn a value into the JavaScript that creates an equivalent value * @param {any} value + * @param {(value: any) => string | void} [replacer] */ -export function uneval(value) { +export function uneval(value, replacer) { const counts = new Map(); /** @type {string[]} */ const keys = []; + const custom = new Map(); + /** @param {any} thing */ function walk(thing) { if (typeof thing === 'function') { @@ -36,6 +39,15 @@ export function uneval(value) { counts.set(thing, 1); + if (replacer) { + const str = replacer(thing); + + if (typeof str === 'string') { + custom.set(thing, str); + return; + } + } + const type = get_type(thing); switch (type) { @@ -117,6 +129,10 @@ export function uneval(value) { return stringify_primitive(thing); } + if (custom.has(thing)) { + return custom.get(thing); + } + const type = get_type(thing); switch (type) { @@ -174,6 +190,11 @@ export function uneval(value) { names.forEach((name, thing) => { params.push(name); + if (custom.has(thing)) { + values.push(/** @type {string} */ (custom.get(thing))); + return; + } + if (is_primitive(thing)) { values.push(stringify_primitive(thing)); return; diff --git a/test/test.js b/test/test.js index d440b41..1857c6b 100644 --- a/test/test.js +++ b/test/test.js @@ -3,6 +3,12 @@ import * as assert from 'uvu/assert'; import * as uvu from 'uvu'; import { uneval, unflatten, parse, stringify } from '../index.js'; +class Custom { + constructor(value) { + this.value = value; + } +} + const fixtures = { basics: [ { @@ -374,14 +380,39 @@ const fixtures = { assert.equal(Object.keys(value).length, 0); } } - ] + ], + + custom: ((instance) => [ + { + name: 'Custom type', + value: [instance, instance], + js: '(function(a){return [a,a]}(new Custom({answer:42})))', + json: '[[1,1],["Custom",2],{"answer":3},42]', + replacer: (value) => { + if (value instanceof Custom) { + return `new Custom(${uneval(value.value)})`; + } + }, + reducers: { + Custom: (x) => x instanceof Custom && x.value + }, + revivers: { + Custom: (x) => new Custom(x) + }, + validate: ([obj1, obj2]) => { + assert.is(obj1, obj2); + assert.ok(obj1 instanceof Custom); + assert.equal(obj1.value.answer, 42); + } + } + ])(new Custom({ answer: 42 })) }; for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`uneval: ${name}`); for (const t of tests) { test(t.name, () => { - const actual = uneval(t.value); + const actual = uneval(t.value, t.replacer); const expected = t.js; assert.equal(actual, expected); }); @@ -393,7 +424,7 @@ for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`stringify: ${name}`); for (const t of tests) { test(t.name, () => { - const actual = stringify(t.value); + const actual = stringify(t.value, t.reducers); const expected = t.json; assert.equal(actual, expected); }); @@ -405,7 +436,7 @@ for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`parse: ${name}`); for (const t of tests) { test(t.name, () => { - const actual = parse(t.json); + const actual = parse(t.json, t.revivers); const expected = t.value; if (t.validate) { @@ -422,7 +453,7 @@ for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`unflatten: ${name}`); for (const t of tests) { test(t.name, () => { - const actual = unflatten(JSON.parse(t.json)); + const actual = unflatten(JSON.parse(t.json), t.revivers); const expected = t.value; if (t.validate) {