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 writesnull, and a function stored under the keytoJSONis 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
toJSONmember 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, atoJSONmethod) has its call rewritten to that traversal; one no JSON writer handles (a Map, abigint) is refused at the call, naming both types. Anunknownmember 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
c54b5e3b3againstdfa7753d3: 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 (1e1f6ba0banda70aa001fagainsta09254dd3: 5,214 identical; the first commit againstda098648f: 5,198 identical). - Native bootstrap (
tests/harness/self-hosting-native-driver.test.ts, 32 vCPU / 64 GB): plain and sanitized pass at1e1f6ba0band ata70aa001f. 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:checkandpnpm test:ts7 --baselines-onlypass.- After the merge of main (
6be6acc4b, one conflict infinalizeRecordLinks, 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 writeTests
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 undertoJSONthrough 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,toJSONon records and classes, an optionaltoJSON, 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
The latest updates on your projects. Learn more about Vercel for GitHub.