mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 15:24:10 +08:00
feat: toJSON method support for custom serialization (#237)
* feat: add toJSON method support for custom serialization * fix: prevent infinite recursion * test: remove redundant toJSON test cases * docs: add custom serialization details for toJSON method * test: fix type issues --------- Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
This commit is contained in:
@@ -330,4 +330,28 @@ Numbers are emitted in canonical decimal form (no exponent notation, no trailing
|
|||||||
|
|
||||||
Decoders accept both decimal and exponent forms on input (e.g., `42`, `-3.14`, `1e-6`), and treat tokens with forbidden leading zeros (e.g., `"05"`) as strings, not numbers.
|
Decoders accept both decimal and exponent forms on input (e.g., `42`, `-3.14`, `1e-6`), and treat tokens with forbidden leading zeros (e.g., `"05"`) as strings, not numbers.
|
||||||
|
|
||||||
|
### Custom Serialization with toJSON
|
||||||
|
|
||||||
|
Objects with a `toJSON()` method are serialized by calling the method and normalizing its result before encoding, similar to `JSON.stringify`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const obj = {
|
||||||
|
data: 'example',
|
||||||
|
toJSON() {
|
||||||
|
return { info: this.data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encode(obj)
|
||||||
|
// info: example
|
||||||
|
```
|
||||||
|
|
||||||
|
The `toJSON()` method:
|
||||||
|
|
||||||
|
- Takes precedence over built-in normalization (Date, Array, Set, Map)
|
||||||
|
- Results are recursively normalized
|
||||||
|
- Is called for objects with `toJSON` in their prototype chain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
For complete rules on quoting, escaping, type conversions, and strict-mode decoding, see [spec §2–4 (data model), §7 (strings and keys), and §14 (strict mode)](https://github.com/toon-format/spec/blob/main/SPEC.md).
|
For complete rules on quoting, escaping, type conversions, and strict-mode decoding, see [spec §2–4 (data model), §7 (strings and keys), and §14 (strict mode)](https://github.com/toon-format/spec/blob/main/SPEC.md).
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Non-JSON-serializable values are normalized before encoding:
|
|||||||
|
|
||||||
| Input | Output |
|
| Input | Output |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
|
| `Object` with `toJSON()` method | Result of calling `toJSON()`, recursively normalized |
|
||||||
| Finite number | Canonical decimal (no exponent, no leading/trailing zeros: `1e6` → `1000000`, `-0` → `0`) |
|
| Finite number | Canonical decimal (no exponent, no leading/trailing zeros: `1e6` → `1000000`, `-0` → `0`) |
|
||||||
| `NaN`, `Infinity`, `-Infinity` | `null` |
|
| `NaN`, `Infinity`, `-Infinity` | `null` |
|
||||||
| `BigInt` (within safe range) | Number |
|
| `BigInt` (within safe range) | Number |
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ export function normalizeValue(value: unknown): JsonValue {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Objects with toJSON: delegate to its result before host-type normalization
|
||||||
|
if (
|
||||||
|
typeof value === 'object'
|
||||||
|
&& value !== null
|
||||||
|
&& 'toJSON' in value
|
||||||
|
&& typeof value.toJSON === 'function'
|
||||||
|
) {
|
||||||
|
const next = value.toJSON()
|
||||||
|
// Avoid infinite recursion when toJSON returns the same object
|
||||||
|
if (next !== value) {
|
||||||
|
return normalizeValue(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
if (typeof value === 'string' || typeof value === 'boolean') {
|
if (typeof value === 'string' || typeof value === 'boolean') {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable test/prefer-lowercase-title */
|
/* eslint-disable test/prefer-lowercase-title */
|
||||||
|
import type { EncodeReplacer } from '../src/index'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { decode, encode } from '../src/index'
|
import { decode, encode } from '../src/index'
|
||||||
|
|
||||||
@@ -112,4 +113,153 @@ describe('JavaScript-specific type normalization', () => {
|
|||||||
expect(result).toBe('0')
|
expect(result).toBe('0')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('toJSON method support', () => {
|
||||||
|
it('calls toJSON method when object has it', () => {
|
||||||
|
const obj = {
|
||||||
|
data: 'example',
|
||||||
|
toJSON() {
|
||||||
|
return { info: this.data }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = encode(obj)
|
||||||
|
expect(result).toBe('info: example')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toJSON returning a primitive', () => {
|
||||||
|
const obj = {
|
||||||
|
value: 42,
|
||||||
|
toJSON() {
|
||||||
|
return 'custom-string'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = encode(obj)
|
||||||
|
expect(result).toBe('custom-string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toJSON returning an array', () => {
|
||||||
|
const obj = {
|
||||||
|
items: [1, 2, 3],
|
||||||
|
toJSON() {
|
||||||
|
return ['a', 'b', 'c']
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = encode(obj)
|
||||||
|
expect(result).toBe('[3]: a,b,c')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toJSON in nested object properties', () => {
|
||||||
|
const nestedObj = {
|
||||||
|
secret: 'hidden',
|
||||||
|
toJSON() {
|
||||||
|
return { public: 'visible' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const obj = {
|
||||||
|
nested: nestedObj,
|
||||||
|
other: 'value',
|
||||||
|
}
|
||||||
|
const result = encode(obj)
|
||||||
|
expect(result).toBe('nested:\n public: visible\nother: value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toJSON in array elements', () => {
|
||||||
|
const obj1 = {
|
||||||
|
data: 'first',
|
||||||
|
toJSON() {
|
||||||
|
return { transformed: 'first-transformed' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const obj2 = {
|
||||||
|
data: 'second',
|
||||||
|
toJSON() {
|
||||||
|
return { transformed: 'second-transformed' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const arr = [obj1, obj2]
|
||||||
|
const result = encode(arr)
|
||||||
|
expect(result).toBe('[2]{transformed}:\n first-transformed\n second-transformed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toJSON takes precedence over Date normalization', () => {
|
||||||
|
const customDate = {
|
||||||
|
toJSON() {
|
||||||
|
return { type: 'custom-date', value: '2025-01-01' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Make it look like a Date but with toJSON
|
||||||
|
Object.setPrototypeOf(customDate, Date.prototype)
|
||||||
|
const result = encode(customDate)
|
||||||
|
expect(result).toBe('type: custom-date\nvalue: 2025-01-01')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works with toJSON inherited from prototype', () => {
|
||||||
|
class CustomClass {
|
||||||
|
value: string
|
||||||
|
|
||||||
|
constructor(value: string) {
|
||||||
|
this.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return { classValue: this.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new CustomClass('test-value')
|
||||||
|
const result = encode(instance)
|
||||||
|
expect(result).toBe('classValue: test-value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles toJSON returning undefined (normalizes to null)', () => {
|
||||||
|
const obj = {
|
||||||
|
data: 'test',
|
||||||
|
toJSON() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = encode(obj)
|
||||||
|
expect(result).toBe('null')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works with replacer function', () => {
|
||||||
|
const obj = {
|
||||||
|
id: 1,
|
||||||
|
secret: 'hidden',
|
||||||
|
toJSON() {
|
||||||
|
return { id: this.id, public: 'visible' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const replacer: EncodeReplacer = (key, value) => {
|
||||||
|
// Replacer should see the toJSON result, not the original object
|
||||||
|
if (typeof value === 'object' && value !== null && 'public' in value) {
|
||||||
|
return { ...value, extra: 'added' }
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const result = encode(obj, { replacer })
|
||||||
|
const decoded = decode(result)
|
||||||
|
expect(decoded).toEqual({ id: 1, public: 'visible', extra: 'added' })
|
||||||
|
expect(decoded).not.toHaveProperty('secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toJSON result is normalized before replacer is applied', () => {
|
||||||
|
const dateObj = {
|
||||||
|
date: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
toJSON() {
|
||||||
|
return { date: this.date }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const replacer: EncodeReplacer = (key, value) => {
|
||||||
|
// The date should already be normalized to ISO string by the time replacer sees it
|
||||||
|
if (key === 'date' && typeof value === 'string') {
|
||||||
|
return value.replace('2025', 'YEAR')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const result = encode(dateObj, { replacer })
|
||||||
|
const decoded = decode(result)
|
||||||
|
expect(decoded).toEqual({ date: 'YEAR-01-01T00:00:00.000Z' })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user