Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support custom types #58

Merged
merged 7 commits into from Feb 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Expand Up @@ -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`:
Expand Down
23 changes: 18 additions & 5 deletions src/parse.js
Expand Up @@ -10,16 +10,18 @@ import {
/**
* Revive a value serialized with `devalue.stringify`
* @param {string} serialized
* @param {Record<string, (value: any) => 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<string, (value: any) => 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) {
Expand All @@ -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;
Expand All @@ -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]);
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion src/stringify.js
Expand Up @@ -17,14 +17,21 @@ import {
/**
* Turn a value into a JSON string that can be parsed with `devalue.parse`
* @param {any} value
* @param {Record<string, (value: any) => any>} [reducers]
*/
export function stringify(value) {
export function stringify(value, reducers) {
/** @type {any[]} */
const stringified = [];

/** @type {Map<any, number>} */
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 = [];

Expand All @@ -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)) {
Expand Down
23 changes: 22 additions & 1 deletion src/uneval.js
Expand Up @@ -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') {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 36 additions & 5 deletions test/test.js
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down