Skip to content

Commit 13e9f65

Browse files
authored
Merge pull request #58 from Rich-Harris/custom-types
support custom types
2 parents b1bf506 + 57a3234 commit 13e9f65

File tree

5 files changed

+135
-12
lines changed

5 files changed

+135
-12
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,49 @@ const json = `{
7878
const data = devalue.unflatten(JSON.parse(json).data);
7979
```
8080

81+
## Custom types
82+
83+
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_:
84+
85+
```js
86+
class Vector {
87+
constructor(x, y) {
88+
this.x = x;
89+
this.y = y;
90+
}
91+
92+
magnitude() {
93+
return Math.sqrt(this.x * this.x + this.y * this.y);
94+
}
95+
}
96+
97+
const stringified = devalue.stringify(new Vector(30, 40), {
98+
Vector: (value) => value instanceof Vector && [value.x, value.y]
99+
});
100+
101+
console.log(stringified); // [["Vector",1],[2,3],30,40]
102+
103+
const vector = devalue.parse(stringified, {
104+
Vector: ([x, y]) => new Vector(x, y)
105+
});
106+
107+
console.log(vector.magnitude()); // 50
108+
```
109+
110+
If a function passed to `stringify` returns a truthy value, it's treated as a match.
111+
112+
You can also use custom types with `uneval` by specifying a custom replacer:
113+
114+
```js
115+
devalue.uneval(vector, (value, uneval) => {
116+
if (value instanceof Vector) {
117+
return `new Vector(${value.x},${value.y})`;
118+
}
119+
}); // `new Vector(30,40)`
120+
```
121+
122+
Note that any variables referenced in the resulting JavaScript (like `Vector` in the example above) must be in scope when it runs.
123+
81124
## Error handling
82125

83126
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`:

src/parse.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ import {
1010
/**
1111
* Revive a value serialized with `devalue.stringify`
1212
* @param {string} serialized
13+
* @param {Record<string, (value: any) => any>} [revivers]
1314
*/
14-
export function parse(serialized) {
15-
return unflatten(JSON.parse(serialized));
15+
export function parse(serialized, revivers) {
16+
return unflatten(JSON.parse(serialized), revivers);
1617
}
1718

1819
/**
19-
* Revive a value flattened with `devalue.flatten`
20+
* Revive a value flattened with `devalue.stringify`
2021
* @param {number | any[]} parsed
22+
* @param {Record<string, (value: any) => any>} [revivers]
2123
*/
22-
export function unflatten(parsed) {
24+
export function unflatten(parsed, revivers) {
2325
if (typeof parsed === 'number') return hydrate(parsed, true);
2426

2527
if (!Array.isArray(parsed) || parsed.length === 0) {
@@ -30,7 +32,10 @@ export function unflatten(parsed) {
3032

3133
const hydrated = Array(values.length);
3234

33-
/** @param {number} index */
35+
/**
36+
* @param {number} index
37+
* @returns {any}
38+
*/
3439
function hydrate(index, standalone = false) {
3540
if (index === UNDEFINED) return undefined;
3641
if (index === NAN) return NaN;
@@ -50,6 +55,11 @@ export function unflatten(parsed) {
5055
if (typeof value[0] === 'string') {
5156
const type = value[0];
5257

58+
const reviver = revivers?.[type];
59+
if (reviver) {
60+
return (hydrated[index] = reviver(hydrate(value[1])));
61+
}
62+
5363
switch (type) {
5464
case 'Date':
5565
hydrated[index] = new Date(value[1]);
@@ -90,6 +100,9 @@ export function unflatten(parsed) {
90100
obj[value[i]] = hydrate(value[i + 1]);
91101
}
92102
break;
103+
104+
default:
105+
throw new Error(`Unknown type ${type}`);
93106
}
94107
} else {
95108
const array = new Array(value.length);

src/stringify.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,21 @@ import {
1717
/**
1818
* Turn a value into a JSON string that can be parsed with `devalue.parse`
1919
* @param {any} value
20+
* @param {Record<string, (value: any) => any>} [reducers]
2021
*/
21-
export function stringify(value) {
22+
export function stringify(value, reducers) {
2223
/** @type {any[]} */
2324
const stringified = [];
2425

2526
/** @type {Map<any, number>} */
2627
const indexes = new Map();
2728

29+
/** @type {Array<{ key: string, fn: (value: any) => any }>} */
30+
const custom = [];
31+
for (const key in reducers) {
32+
custom.push({ key, fn: reducers[key] });
33+
}
34+
2835
/** @type {string[]} */
2936
const keys = [];
3037

@@ -47,6 +54,14 @@ export function stringify(value) {
4754
const index = p++;
4855
indexes.set(thing, index);
4956

57+
for (const { key, fn } of custom) {
58+
const value = fn(thing);
59+
if (value) {
60+
stringified[index] = `["${key}",${flatten(value)}]`;
61+
return index;
62+
}
63+
}
64+
5065
let str = '';
5166

5267
if (is_primitive(thing)) {

src/uneval.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ const reserved =
1515
/**
1616
* Turn a value into the JavaScript that creates an equivalent value
1717
* @param {any} value
18+
* @param {(value: any) => string | void} [replacer]
1819
*/
19-
export function uneval(value) {
20+
export function uneval(value, replacer) {
2021
const counts = new Map();
2122

2223
/** @type {string[]} */
2324
const keys = [];
2425

26+
const custom = new Map();
27+
2528
/** @param {any} thing */
2629
function walk(thing) {
2730
if (typeof thing === 'function') {
@@ -36,6 +39,15 @@ export function uneval(value) {
3639

3740
counts.set(thing, 1);
3841

42+
if (replacer) {
43+
const str = replacer(thing);
44+
45+
if (typeof str === 'string') {
46+
custom.set(thing, str);
47+
return;
48+
}
49+
}
50+
3951
const type = get_type(thing);
4052

4153
switch (type) {
@@ -117,6 +129,10 @@ export function uneval(value) {
117129
return stringify_primitive(thing);
118130
}
119131

132+
if (custom.has(thing)) {
133+
return custom.get(thing);
134+
}
135+
120136
const type = get_type(thing);
121137

122138
switch (type) {
@@ -174,6 +190,11 @@ export function uneval(value) {
174190
names.forEach((name, thing) => {
175191
params.push(name);
176192

193+
if (custom.has(thing)) {
194+
values.push(/** @type {string} */ (custom.get(thing)));
195+
return;
196+
}
197+
177198
if (is_primitive(thing)) {
178199
values.push(stringify_primitive(thing));
179200
return;

test/test.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import * as assert from 'uvu/assert';
33
import * as uvu from 'uvu';
44
import { uneval, unflatten, parse, stringify } from '../index.js';
55

6+
class Custom {
7+
constructor(value) {
8+
this.value = value;
9+
}
10+
}
11+
612
const fixtures = {
713
basics: [
814
{
@@ -374,14 +380,39 @@ const fixtures = {
374380
assert.equal(Object.keys(value).length, 0);
375381
}
376382
}
377-
]
383+
],
384+
385+
custom: ((instance) => [
386+
{
387+
name: 'Custom type',
388+
value: [instance, instance],
389+
js: '(function(a){return [a,a]}(new Custom({answer:42})))',
390+
json: '[[1,1],["Custom",2],{"answer":3},42]',
391+
replacer: (value) => {
392+
if (value instanceof Custom) {
393+
return `new Custom(${uneval(value.value)})`;
394+
}
395+
},
396+
reducers: {
397+
Custom: (x) => x instanceof Custom && x.value
398+
},
399+
revivers: {
400+
Custom: (x) => new Custom(x)
401+
},
402+
validate: ([obj1, obj2]) => {
403+
assert.is(obj1, obj2);
404+
assert.ok(obj1 instanceof Custom);
405+
assert.equal(obj1.value.answer, 42);
406+
}
407+
}
408+
])(new Custom({ answer: 42 }))
378409
};
379410

380411
for (const [name, tests] of Object.entries(fixtures)) {
381412
const test = uvu.suite(`uneval: ${name}`);
382413
for (const t of tests) {
383414
test(t.name, () => {
384-
const actual = uneval(t.value);
415+
const actual = uneval(t.value, t.replacer);
385416
const expected = t.js;
386417
assert.equal(actual, expected);
387418
});
@@ -393,7 +424,7 @@ for (const [name, tests] of Object.entries(fixtures)) {
393424
const test = uvu.suite(`stringify: ${name}`);
394425
for (const t of tests) {
395426
test(t.name, () => {
396-
const actual = stringify(t.value);
427+
const actual = stringify(t.value, t.reducers);
397428
const expected = t.json;
398429
assert.equal(actual, expected);
399430
});
@@ -405,7 +436,7 @@ for (const [name, tests] of Object.entries(fixtures)) {
405436
const test = uvu.suite(`parse: ${name}`);
406437
for (const t of tests) {
407438
test(t.name, () => {
408-
const actual = parse(t.json);
439+
const actual = parse(t.json, t.revivers);
409440
const expected = t.value;
410441

411442
if (t.validate) {
@@ -422,7 +453,7 @@ for (const [name, tests] of Object.entries(fixtures)) {
422453
const test = uvu.suite(`unflatten: ${name}`);
423454
for (const t of tests) {
424455
test(t.name, () => {
425-
const actual = unflatten(JSON.parse(t.json));
456+
const actual = unflatten(JSON.parse(t.json), t.revivers);
426457
const expected = t.value;
427458

428459
if (t.validate) {

0 commit comments

Comments
 (0)