diff --git a/docs/guide/format-overview.md b/docs/guide/format-overview.md index 1c8227c..bf906a7 100644 --- a/docs/guide/format-overview.md +++ b/docs/guide/format-overview.md @@ -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. +### 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). diff --git a/docs/reference/api.md b/docs/reference/api.md index aab33c6..b0e33e1 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -54,6 +54,7 @@ Non-JSON-serializable values are normalized before encoding: | 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`) | | `NaN`, `Infinity`, `-Infinity` | `null` | | `BigInt` (within safe range) | Number | diff --git a/packages/toon/src/encode/normalize.ts b/packages/toon/src/encode/normalize.ts index 89d85ba..2f5565d 100644 --- a/packages/toon/src/encode/normalize.ts +++ b/packages/toon/src/encode/normalize.ts @@ -8,6 +8,20 @@ export function normalizeValue(value: unknown): JsonValue { 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 if (typeof value === 'string' || typeof value === 'boolean') { return value diff --git a/packages/toon/test/normalization.test.ts b/packages/toon/test/normalization.test.ts index 7344d9e..c401fd9 100644 --- a/packages/toon/test/normalization.test.ts +++ b/packages/toon/test/normalization.test.ts @@ -1,4 +1,5 @@ /* eslint-disable test/prefer-lowercase-title */ +import type { EncodeReplacer } from '../src/index' import { describe, expect, it } from 'vitest' import { decode, encode } from '../src/index' @@ -112,4 +113,153 @@ describe('JavaScript-specific type normalization', () => { 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' }) + }) + }) })