Skip to content

Commit a4538b4

Browse files
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>
1 parent 7ed9701 commit a4538b4

File tree

4 files changed

+189
-0
lines changed

4 files changed

+189
-0
lines changed

docs/guide/format-overview.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,4 +330,28 @@ Numbers are emitted in canonical decimal form (no exponent notation, no trailing
330330

331331
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.
332332

333+
### Custom Serialization with toJSON
334+
335+
Objects with a `toJSON()` method are serialized by calling the method and normalizing its result before encoding, similar to `JSON.stringify`:
336+
337+
```ts
338+
const obj = {
339+
data: 'example',
340+
toJSON() {
341+
return { info: this.data }
342+
}
343+
}
344+
345+
encode(obj)
346+
// info: example
347+
```
348+
349+
The `toJSON()` method:
350+
351+
- Takes precedence over built-in normalization (Date, Array, Set, Map)
352+
- Results are recursively normalized
353+
- Is called for objects with `toJSON` in their prototype chain
354+
355+
---
356+
333357
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).

docs/reference/api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Non-JSON-serializable values are normalized before encoding:
5454

5555
| Input | Output |
5656
|-------|--------|
57+
| `Object` with `toJSON()` method | Result of calling `toJSON()`, recursively normalized |
5758
| Finite number | Canonical decimal (no exponent, no leading/trailing zeros: `1e6``1000000`, `-0``0`) |
5859
| `NaN`, `Infinity`, `-Infinity` | `null` |
5960
| `BigInt` (within safe range) | Number |

packages/toon/src/encode/normalize.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ export function normalizeValue(value: unknown): JsonValue {
88
return null
99
}
1010

11+
// Objects with toJSON: delegate to its result before host-type normalization
12+
if (
13+
typeof value === 'object'
14+
&& value !== null
15+
&& 'toJSON' in value
16+
&& typeof value.toJSON === 'function'
17+
) {
18+
const next = value.toJSON()
19+
// Avoid infinite recursion when toJSON returns the same object
20+
if (next !== value) {
21+
return normalizeValue(next)
22+
}
23+
}
24+
1125
// Primitives
1226
if (typeof value === 'string' || typeof value === 'boolean') {
1327
return value

packages/toon/test/normalization.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable test/prefer-lowercase-title */
2+
import type { EncodeReplacer } from '../src/index'
23
import { describe, expect, it } from 'vitest'
34
import { decode, encode } from '../src/index'
45

@@ -112,4 +113,153 @@ describe('JavaScript-specific type normalization', () => {
112113
expect(result).toBe('0')
113114
})
114115
})
116+
117+
describe('toJSON method support', () => {
118+
it('calls toJSON method when object has it', () => {
119+
const obj = {
120+
data: 'example',
121+
toJSON() {
122+
return { info: this.data }
123+
},
124+
}
125+
const result = encode(obj)
126+
expect(result).toBe('info: example')
127+
})
128+
129+
it('calls toJSON returning a primitive', () => {
130+
const obj = {
131+
value: 42,
132+
toJSON() {
133+
return 'custom-string'
134+
},
135+
}
136+
const result = encode(obj)
137+
expect(result).toBe('custom-string')
138+
})
139+
140+
it('calls toJSON returning an array', () => {
141+
const obj = {
142+
items: [1, 2, 3],
143+
toJSON() {
144+
return ['a', 'b', 'c']
145+
},
146+
}
147+
const result = encode(obj)
148+
expect(result).toBe('[3]: a,b,c')
149+
})
150+
151+
it('calls toJSON in nested object properties', () => {
152+
const nestedObj = {
153+
secret: 'hidden',
154+
toJSON() {
155+
return { public: 'visible' }
156+
},
157+
}
158+
const obj = {
159+
nested: nestedObj,
160+
other: 'value',
161+
}
162+
const result = encode(obj)
163+
expect(result).toBe('nested:\n public: visible\nother: value')
164+
})
165+
166+
it('calls toJSON in array elements', () => {
167+
const obj1 = {
168+
data: 'first',
169+
toJSON() {
170+
return { transformed: 'first-transformed' }
171+
},
172+
}
173+
const obj2 = {
174+
data: 'second',
175+
toJSON() {
176+
return { transformed: 'second-transformed' }
177+
},
178+
}
179+
const arr = [obj1, obj2]
180+
const result = encode(arr)
181+
expect(result).toBe('[2]{transformed}:\n first-transformed\n second-transformed')
182+
})
183+
184+
it('toJSON takes precedence over Date normalization', () => {
185+
const customDate = {
186+
toJSON() {
187+
return { type: 'custom-date', value: '2025-01-01' }
188+
},
189+
}
190+
// Make it look like a Date but with toJSON
191+
Object.setPrototypeOf(customDate, Date.prototype)
192+
const result = encode(customDate)
193+
expect(result).toBe('type: custom-date\nvalue: 2025-01-01')
194+
})
195+
196+
it('works with toJSON inherited from prototype', () => {
197+
class CustomClass {
198+
value: string
199+
200+
constructor(value: string) {
201+
this.value = value
202+
}
203+
204+
toJSON() {
205+
return { classValue: this.value }
206+
}
207+
}
208+
209+
const instance = new CustomClass('test-value')
210+
const result = encode(instance)
211+
expect(result).toBe('classValue: test-value')
212+
})
213+
214+
it('handles toJSON returning undefined (normalizes to null)', () => {
215+
const obj = {
216+
data: 'test',
217+
toJSON() {
218+
return undefined
219+
},
220+
}
221+
const result = encode(obj)
222+
expect(result).toBe('null')
223+
})
224+
225+
it('works with replacer function', () => {
226+
const obj = {
227+
id: 1,
228+
secret: 'hidden',
229+
toJSON() {
230+
return { id: this.id, public: 'visible' }
231+
},
232+
}
233+
const replacer: EncodeReplacer = (key, value) => {
234+
// Replacer should see the toJSON result, not the original object
235+
if (typeof value === 'object' && value !== null && 'public' in value) {
236+
return { ...value, extra: 'added' }
237+
}
238+
return value
239+
}
240+
const result = encode(obj, { replacer })
241+
const decoded = decode(result)
242+
expect(decoded).toEqual({ id: 1, public: 'visible', extra: 'added' })
243+
expect(decoded).not.toHaveProperty('secret')
244+
})
245+
246+
it('toJSON result is normalized before replacer is applied', () => {
247+
const dateObj = {
248+
date: new Date('2025-01-01T00:00:00.000Z'),
249+
toJSON() {
250+
return { date: this.date }
251+
},
252+
}
253+
const replacer: EncodeReplacer = (key, value) => {
254+
// The date should already be normalized to ISO string by the time replacer sees it
255+
if (key === 'date' && typeof value === 'string') {
256+
return value.replace('2025', 'YEAR')
257+
}
258+
return value
259+
}
260+
const result = encode(dateObj, { replacer })
261+
const decoded = decode(result)
262+
expect(decoded).toEqual({ date: 'YEAR-01-01T00:00:00.000Z' })
263+
})
264+
})
115265
})

0 commit comments

Comments
 (0)