Skip to content

Commit

Permalink
Merge pull request #58 from Rich-Harris/custom-types
Browse files Browse the repository at this point in the history
support custom types
  • Loading branch information
Rich-Harris committed Feb 10, 2023
2 parents b1bf506 + 57a3234 commit 13e9f65
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 12 deletions.
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

0 comments on commit 13e9f65

Please sign in to comment.