#3188

Serialize function-valued members under JSON.stringify

Opencramforcewants to merge intofrom

What this fixes

JSON.stringify of a typed value could end a whole build with one diagnostic at the entry file's first byte:

src/cli.ts:1:1 - error SC3001: the LLVM backend does not support this construct yet (jsonStringify:func)

Five lines reproduce it on main:

interface Wide { a: number; b: string; pick?: (x: any) => any }
interface Narrow { a: number }
function narrow(w: Wide): Narrow { return w; }
const w: Wide = { a: 1, b: "s", pick: (x) => x };
console.log(JSON.stringify(narrow(w)));

The narrow value is a copy linked to its source (IrModule.recordLinks), and the typed JSON writer serializes a linked copy as a record of the source's type. Neither the frontend fence nor the IR validator looked at that source type, so whatever it holds reached the writer: an optional callback gives jsonStringify:func, a Map gives jsonStringify:map. Everywhere else a function below the root was refused at the call (SC1090), except a required function member of a plain record, which the writer omits.

What changes

No backend change. The stringify predicate (isJsonSafeAt) and the frontend decide which of the two existing serializers a call takes:

  • A function below the root takes the runtime JSON traversal, which already owns JavaScript's member rules: a property holding a function is omitted (an optional field, a string | (() => string) field while it holds the function, an index-signature entry), an array or tuple slot holding one writes null, and a function stored under the key toJSON is called. The type-directed writer keeps omitting a required function member, as on main, and gains no case.
  • A function as the value itself goes through the same traversal and answers undefined. A root that may be a function (string | (() => string)) stays refused at the call, with wording that says why.
  • A record with a toJSON member takes the runtime traversal, which calls it. On main the typed writer ignored the hook and wrote the data fields: JSON.stringify({ x: 1, toJSON() { return "custom" } }) printed {"x":1} where Node prints "custom". This is the one change for programs that compiled before, and it is a correction; no program in the gate has the shape.
  • Link sources count. The predicate takes the module's links, and the validator passes mod.recordLinks. When links are final (finalizeRecordLinks) the frontend decides each call whose type reaches a kept link by the link's source type: one the typed writer serializes changes nothing; one that needs the runtime traversal (a function outside a required member, a class instance, a toJSON method) has its call rewritten to that traversal; one no JSON writer handles (a Map, a bigint) is refused at the call, naming both types. An unknown member of a source is still written by the typed writer, as on main, so every program that compiled before keeps its output; a follow-up PR sends those calls to the runtime traversal too, which is the exact behaviour and changes one corpus program and the compiler's own build.

The first commit added function cases to the typed writer instead. The review bot found that an index signature of functions could hold the hook under the key toJSON (handlers["toJSON"] = …), which a type-directed writer cannot see; the second commit removes those cases and routes every such type to the runtime traversal. The bot then found that the rewrite looked at one link source only, and after that that the link targets were gathered by a predicate that stops at the first unserializable member. The third and fourth commits fix both: the targets come from a walk of the whole type, and the call takes the runtime traversal only when every source of every target is serializable on some path; otherwise the refusal names the first source that is not.

Class instances were already right (own data properties, methods absent, arrow-function fields omitted), as were replacer functions, replacer arrays, NaN, ±Infinity, -0, Dates and symbol-keyed properties. Map, Set, bigint and symbol values remain named refusals (SC1090).

Not fixed here, and the same on main: a function that carries a toJSON property of its own (Object.assign(fn, { toJSON })) is omitted or written as null; Node calls the property. Both serializers do this today, so it needs a change in the runtime walker.

Proof

  • Zero cost. Gate over the benchmarks, the self-hosted compiler and the whole corpus (TypeScript and JavaScript), head c54b5e3b3 against dfa7753d3: PASS, 5,247 programs identical, 103 fail to build on both sides, none differ. Compile time within tolerance (self -0.52% [95% CI -1.43%, +1.04%], benchmarks +0.03%, fixtures -1.41%). The same verdict at each earlier head (1e1f6ba0b and a70aa001f against a09254dd3: 5,214 identical; the first commit against da098648f: 5,198 identical).
  • Native bootstrap (tests/harness/self-hosting-native-driver.test.ts, 32 vCPU / 64 GB): plain and sanitized pass at 1e1f6ba0b and at a70aa001f. The fourth commit and the merge of main came after; they change only which calls are refused.
  • Differential corpus: the four new programs match Node byte for byte, plain and sanitized, release and dev. The 946 existing corpus programs whose names mention JSON, records, links, sources, formatting, inspection or IPC still pass.
  • Diagnostics corpus: 306 fixtures pass; one snapshot changes (json-dyn.ts: the function-root refusal is gone) and one is new. ir.test.ts: 43 tests pass.
  • pnpm lint, pnpm format:check and pnpm test:ts7 --baselines-only pass.
  • After the merge of main (6be6acc4b, one conflict in finalizeRecordLinks, both lines kept): lint, format, baselines, unit and diagnostics tests (352) and the 814 related corpus programs pass.
  • The reproduction above compiles and prints what Node prints. With a Map in the wide type it reports, at the call:
src/main.ts:6:13 - error SC1090: JSON.stringify of '{ a: number }' values converted from '{ a: number; big: bigint; m: Map<string, number> }' (the value keeps its source object's other properties, and that type has members the JSON writer cannot serialize) is not supported yet
  hint: serialize an object literal that names the properties to write

Tests

  • tests/corpus/json-stringify-function-members.ts: required and optional callback fields, union-armed fields, nested trees, indents, reassignment between text and function.
  • tests/corpus/json-stringify-function-elements.ts: arrays, tuples, index signatures, a mixed index signature with declared fields, a function stored under toJSON through each index signature, nested arrays, the function root.
  • tests/corpus/json-stringify-function-linked-copy.ts: narrowed copies of a wider record with callbacks (the reproduction above), spread copies, rest copies.
  • tests/corpus/json-stringify-function-hooks.ts: class instances beside functions, toJSON on records and classes, an optional toJSON, replacer function and array, cycles through records that hold functions.
  • tests/diagnostics/json-stringify-function-fences.ts: the root union, the linked source with a Map, two link sources of which one holds a class instance the runtime traversal cannot write, and the same in a later member, all at the call.
  • packages/compiler/src/ir/ir.test.ts: which serializer each position selects, the hook, link sources.

🤖 Generated with Claude Code

Activity

13
vercel[bot]6d ago

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 11:48am UTC
vercel[bot]reviewed6d ago
cramforcereviewed6d ago
vercel[bot]reviewed6d ago
cramforcereviewed6d ago
vercel[bot]reviewed6d ago
cramforcereviewed6d ago