From b119b4cf0352fb95e173c14d9f66f50b9845eaff Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 17:32:17 -0500 Subject: [PATCH 1/2] Stringify class instances through the runtime JSON traversal JSON.stringify of a class instance, or of a record, array or union that holds one, boxes the value and serializes it with the runtime JSON traversal. The output matches Node: own enumerable properties in creation order, properties added after construction, toJSON on the instance or a base class, and no methods, accessors, #private fields or static fields. A class whose declared field cannot be read through the checked-dynamic property view, such as a Date field, is refused at compile time with SC1090 naming the field. Boxed Map and Set values now stringify as {}, as Node does, instead of throwing. --- .../language-features/class-reflection.mdx | 2 +- .../src/frontend/lowering/builtins/json.ts | 70 ++++++++++ packages/compiler/src/ir/ir.test.ts | 11 ++ packages/compiler/src/ir/ir.ts | 15 +- ...000-json-stringify-class-instances.ts.json | 6 + ...-json-stringify-js-class-instances.js.json | 6 + .../diagnostics/json-class-fields.ts.json | 6 + packages/runtime/src/scr_json.c | 22 ++- .../6000-json-stringify-class-instances.ts | 131 ++++++++++++++++++ .../6001-json-stringify-js-class-instances.js | 49 +++++++ tests/diagnostics/json-class-fields.ts | 15 ++ .../__snapshots__/json-class-fields.ts.txt | 13 ++ tests/harness/corpus-costs.json | 4 + 13 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json create mode 100644 tests/corpus/6000-json-stringify-class-instances.ts create mode 100644 tests/corpus/6001-json-stringify-js-class-instances.js create mode 100644 tests/diagnostics/json-class-fields.ts create mode 100644 tests/harness/__snapshots__/json-class-fields.ts.txt diff --git a/docs/content/limitations/language-features/class-reflection.mdx b/docs/content/limitations/language-features/class-reflection.mdx index 4e2b9f299..8b0738cc7 100644 --- a/docs/content/limitations/language-features/class-reflection.mdx +++ b/docs/content/limitations/language-features/class-reflection.mdx @@ -1 +1 @@ -- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. They raise an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. +- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. `JSON.stringify` writes Map and Set fields as `{}`, as Node.js does, and refuses classes with Date fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index a73c9d5e7..c99a99c75 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -8,6 +8,10 @@ import { isJsonStringifyDynamicType, BOOL, DYN, + type IrType, + classDynViewSupported, + isClassOwnEnumerableFieldName, + isDynTypedRefType, type IrExpr, STRING, type SrcLoc, @@ -157,6 +161,16 @@ export function lowerJsonMethodCall( (id) => lowerer.unions.get(id), ) ) { + const unreadable = classFieldWithoutJsonView(lowerer, value.type, new Set()); + if (unreadable !== null) { + lowerer.unsupported( + "SC1090", + argNode, + `JSON.stringify of '${lowerer.fmt(value.type)}' values (field '${unreadable.field}' of class ` + + `'${unreadable.className}' has type '${lowerer.fmt(unreadable.type)}', which the runtime JSON ` + + `traversal cannot read)`, + ); + } // Keep the original containers live while the runtime visits them: // a toJSON callback can mutate a later field, and shared/cyclic // references must retain their identities throughout traversal. @@ -276,6 +290,62 @@ function lowerOptionalStringifyRoot( return { kind: "call", callee: helper, args: [value], type: resultT, loc }; } +/** The first class field reachable from a stringify input that the class's + * checked-dynamic property view cannot represent. The runtime JSON traversal + * reads class instances through that view, so such a class is refused at + * compile time instead of throwing when the view is built. */ +function classFieldWithoutJsonView( + lowerer: Lowerer, + t: IrType, + visiting: Set, +): { className: string; field: string; type: IrType } | null { + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + switch (t.kind) { + case "array": + return classFieldWithoutJsonView(lowerer, t.elem, visiting); + case "record": { + if (visiting.has(t.shapeId)) return null; + visiting.add(t.shapeId); + const shape = getRecord(t.shapeId); + for (const field of shape?.fields ?? []) { + const found = classFieldWithoutJsonView(lowerer, field.type, visiting); + if (found) return found; + } + return shape?.indexValue + ? classFieldWithoutJsonView(lowerer, shape.indexValue, visiting) + : null; + } + case "union": { + if (visiting.has(t.unionId)) return null; + visiting.add(t.unionId); + for (const arm of getUnion(t.unionId)?.arms ?? []) { + const found = classFieldWithoutJsonView(lowerer, arm, visiting); + if (found) return found; + } + return null; + } + case "object": { + if (!isDynTypedRefType(t) || visiting.has(t.className)) return null; + visiting.add(t.className); + const info = lowerer.classes.get(t.className); + for (const field of info?.def.fields ?? []) { + if (!isClassOwnEnumerableFieldName(field.name)) continue; + if (!classDynViewSupported([field], getRecord, getUnion)) { + const className = + info?.def.jsName ?? info?.decl?.name?.text ?? t.className.replace(/^%/, ""); + return { className, field: field.name, type: field.type }; + } + const found = classFieldWithoutJsonView(lowerer, field.type, visiting); + if (found) return found; + } + return null; + } + default: + return null; + } +} + /** Only explicit null/undefined select the no-callback path. */ function jsonNullishArgument(lowerer: Lowerer, node: ts.Expression): boolean { if (ts.isParenthesizedExpression(node)) return jsonNullishArgument(lowerer, node.expression); diff --git a/packages/compiler/src/ir/ir.test.ts b/packages/compiler/src/ir/ir.test.ts index 60ecc9ad0..52ef9b9c1 100644 --- a/packages/compiler/src/ir/ir.test.ts +++ b/packages/compiler/src/ir/ir.test.ts @@ -159,6 +159,17 @@ describe("checked records with opaque payloads", () => { isJsonStringifyDynamicType(arrayOf({ kind: "union", unionId: "optional" }), record, union), ).toBe(true); }); + + test("class instances stringify through the runtime JSON traversal only", () => { + const point: IrType = { kind: "object", className: "Point" }; + const failure: IrType = { kind: "object", className: "%Error" }; + expect(isJsonStringifyDynamicType(point, record, union)).toBe(true); + expect(isJsonStringifyDynamicType(arrayOf(point), record, union)).toBe(true); + expect(isJsonStringifyDynamicType(failure, record, union)).toBe(false); + expect(isJsonStringifySafeType(point, record, union)).toBe(false); + expect(isJsonSafeType(point, record, union)).toBe(false); + expect(canDynCheckTo(arrayOf(point), record, union)).toBe(true); + }); }); describe("native typed-array boundaries", () => { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 68a4776f5..8c5299a23 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3717,9 +3717,10 @@ export function isJsonStringifySafeType( return isJsonSafeAt(t, getRecord, getUnion, true, false, new Set()); } -/** A JSON-shaped container with unknown payloads needs the runtime JSON - * traversal. Its declared structure can be boxed, but payloads may carry - * omitted values, toJSON methods, or cycles. This predicate does not widen +/** A JSON-shaped container with unknown payloads, or a class instance at + * any position, needs the runtime JSON traversal. Its declared structure can + * be boxed, but payloads may carry omitted values, toJSON methods, added + * properties, or cycles. This predicate does not widen * the type-directed serializer, checked-value boundary, or root-undefined * contract; callers must select the checked runtime traversal explicitly. */ export function isJsonStringifyDynamicType( @@ -3728,7 +3729,7 @@ export function isJsonStringifyDynamicType( getUnion: (unionId: string) => IrUnionDef | undefined, ): boolean { return ( - (t.kind === "record" || t.kind === "array" || t.kind === "union") && + (t.kind === "record" || t.kind === "array" || t.kind === "union" || isDynTypedRefType(t)) && isJsonSafeAt(t, getRecord, getUnion, true, false, new Set(), true) && !isJsonStringifySafeType(t, getRecord, getUnion) ); @@ -3838,8 +3839,12 @@ function isJsonSafeAt( return nativeFields && !stringify; case "bytes": return nativeFields; - case "func": + // Class instances stringify through the runtime JSON traversal, which + // reads their own enumerable properties and calls toJSON. case "object": + if (nativeFields && stringify && isDynTypedRefType(t)) return true; + return false; + case "func": // Class values stringify as "{}" husks in Node (own enumerable statics // aside — not representable type-directedly); rejected like Maps. case "classval": diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json new file mode 100644 index 000000000..93c106c07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6000-json-stringify-class-instances.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json new file mode 100644 index 000000000..8ecb709f3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6001-json-stringify-js-class-instances.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json new file mode 100644 index 000000000..ee3ed6bad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/json-class-fields.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b96a19757..0c45d958e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -12818,8 +12818,15 @@ static ScrDyn *scr_native_set_pack(void *ptr) { ScrDyn *scr_dyn_native_set(ScrMap *value) { static const ScrDynHandleOps ops = { - "Set", &scr_map_retain_v, &scr_map_release_v, &scr_native_set_invoke, - &scr_native_set_get, &scr_weak_map_write, NULL, &scr_native_set_pack, + .cls = "Set", + .retain = &scr_map_retain_v, + .release = &scr_map_release_v, + .invoke = &scr_native_set_invoke, + .get = &scr_native_set_get, + .set = &scr_weak_map_write, + .iter_pack = &scr_native_set_pack, + /* Entries live in internal slots: JSON.stringify writes {}, as Node. */ + .json_empty_object = true, }; scr_dyn_handle_install(SCR_DYNH_SET, &ops); return scr_dyn_new_handle(value, SCR_DYNH_SET); @@ -12928,8 +12935,15 @@ static ScrDyn *scr_native_map_pack(void *ptr) { ScrDyn *scr_dyn_native_map(ScrMap *value) { static const ScrDynHandleOps ops = { - "Map", &scr_map_retain_v, &scr_map_release_v, &scr_native_map_invoke, - &scr_native_map_get, &scr_weak_map_write, NULL, &scr_native_map_pack, + .cls = "Map", + .retain = &scr_map_retain_v, + .release = &scr_map_release_v, + .invoke = &scr_native_map_invoke, + .get = &scr_native_map_get, + .set = &scr_weak_map_write, + .iter_pack = &scr_native_map_pack, + /* Entries live in internal slots: JSON.stringify writes {}, as Node. */ + .json_empty_object = true, }; scr_dyn_handle_install(SCR_DYNH_MAP, &ops); return scr_dyn_new_handle(value, SCR_DYNH_MAP); diff --git a/tests/corpus/6000-json-stringify-class-instances.ts b/tests/corpus/6000-json-stringify-class-instances.ts new file mode 100644 index 000000000..a73168b40 --- /dev/null +++ b/tests/corpus/6000-json-stringify-class-instances.ts @@ -0,0 +1,131 @@ +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + norm(): number { + return Math.hypot(this.x, this.y); + } +} + +class Account { + static count = 0; + #secret = "hidden"; + private balance = 10; + protected owner = "ada"; + label: string | undefined = undefined; + tags: string[] = ["a", "b"]; + get secret(): string { + return this.#secret; + } +} + +class Stamp { + at = 5; + toJSON(key: string): string { + return `stamp(${key})`; + } +} + +class Base { + kind = "base"; + toJSON(): { kind: string; via: string } { + return { kind: this.kind, via: "Base.toJSON" }; + } +} +class Child extends Base { + extra = 1; +} + +class Labeled extends Point { + name = "p"; +} + +class Box { + value: T; + constructor(value: T) { + this.value = value; + } +} + +class Registry { + entries = new Map([["a", 1]]); + count = 2; +} + +class Cycle { + name = "loop"; + next: Cycle | null = null; +} + +class Big { + n = 10n; +} + +const tag = Symbol("tag"); + +class Handlers { + name = "h"; + onEvent = (n: number): number => n + 1; + payload: unknown = { a: [1, "two", null] }; + [tag] = "symbol-keyed"; +} + +class Failure extends Error { + code = "E_FAIL"; +} + +class Empty {} + +class Tagged { + s = new Set([1, 2]); + count = 2; +} + +const p = new Point(3, 4); +console.log(JSON.stringify(p)); +console.log(JSON.stringify(new Account())); +console.log(JSON.stringify(new Stamp())); +console.log(JSON.stringify({ first: new Stamp(), list: [new Stamp()] })); +console.log(JSON.stringify(new Child())); +console.log(JSON.stringify(new Labeled(1, 2))); +console.log(JSON.stringify(new Box("text")), JSON.stringify(new Box(new Point(0, 1)))); +console.log(JSON.stringify(new Registry())); +console.log(JSON.stringify([p, null, new Point(5, 6)])); +console.log(JSON.stringify({ point: p, points: [p, p] }, null, 2)); +console.log(JSON.stringify(p, null, "--")); +console.log(JSON.stringify(p, ["x"])); +console.log(JSON.stringify(p, (key: string, value: unknown) => (key === "y" ? undefined : value))); + +const maybe: Point | null = Math.random() < 2 ? p : null; +console.log(JSON.stringify(maybe)); + +const loop = new Cycle(); +loop.next = loop; +try { + JSON.stringify(loop); + console.log("no error"); +} catch (e) { + console.log(e instanceof TypeError); +} + +try { + JSON.stringify(new Big()); + console.log("no error"); +} catch (e) { + console.log(e instanceof TypeError); +} + +p.x = 30; +console.log(JSON.stringify(p)); + +const h = new Handlers(); +console.log(JSON.stringify(h)); +console.log(JSON.stringify(new Empty())); +console.log(JSON.stringify(new Failure("boom"))); +h.payload = 7; +console.log(JSON.stringify({ handlers: [h] })); +const u: unknown = new Map([["a", 1]]); +console.log(JSON.stringify(new Tagged()), JSON.stringify(u)); diff --git a/tests/corpus/6001-json-stringify-js-class-instances.js b/tests/corpus/6001-json-stringify-js-class-instances.js new file mode 100644 index 000000000..d270a632f --- /dev/null +++ b/tests/corpus/6001-json-stringify-js-class-instances.js @@ -0,0 +1,49 @@ +class Box { + constructor(v) { + this.v = v; + } +} + +class Plain {} + +class Late { + constructor() { + this.a = 1; + } + setB() { + this.b = 2; + } + toString() { + return "late"; + } +} + +class Shaped { + constructor(n) { + this.n = n; + } + toJSON() { + return { shaped: this.n }; + } +} + +const b = new Box(1); +console.log(JSON.stringify(b)); +b.extra = "e"; +console.log(JSON.stringify(b)); + +const q = new Plain(); +console.log(JSON.stringify(q)); +q.a = 1; +q.inner = new Box(2); +q.list = [new Box(3), null]; +console.log(JSON.stringify(q)); +console.log(JSON.stringify([q, b], null, 1)); + +const late = new Late(); +console.log(JSON.stringify(late)); +late.c = 3; +late.setB(); +console.log(JSON.stringify(late)); + +console.log(JSON.stringify({ s: new Shaped(4), arr: [new Shaped(5)] })); diff --git a/tests/diagnostics/json-class-fields.ts b/tests/diagnostics/json-class-fields.ts new file mode 100644 index 000000000..81012c161 --- /dev/null +++ b/tests/diagnostics/json-class-fields.ts @@ -0,0 +1,15 @@ +// JSON.stringify reads class instances through their checked-dynamic +// property view. A class whose declared field has no representation in +// that view is refused at compile time, naming the field. +class Schedule { + name = "weekly"; + next = new Date(Date.UTC(2024, 0, 1)); +} + +class Calendar { + title = "team"; + schedules: Schedule[] = [new Schedule()]; +} + +console.log(JSON.stringify(new Schedule())); +console.log(JSON.stringify({ calendar: new Calendar() })); diff --git a/tests/harness/__snapshots__/json-class-fields.ts.txt b/tests/harness/__snapshots__/json-class-fields.ts.txt new file mode 100644 index 000000000..568cfa47f --- /dev/null +++ b/tests/harness/__snapshots__/json-class-fields.ts.txt @@ -0,0 +1,13 @@ +json-class-fields.ts:14:28 - error SC1090: JSON.stringify of 'Schedule' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet + + 13 | + 14 | console.log(JSON.stringify(new Schedule())); + | ^~~~~~~~~~~~~~ + 15 | console.log(JSON.stringify({ calendar: new Calendar() })); + +json-class-fields.ts:15:28 - error SC1090: JSON.stringify of '{ calendar: Calendar }' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet + + 14 | console.log(JSON.stringify(new Schedule())); + 15 | console.log(JSON.stringify({ calendar: new Calendar() })); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 16 | \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 18147374d..2dd8a90db 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2201,6 +2201,8 @@ "5995-array-dense-loop-boundaries.ts": 2596, "5996-rejections-between-macrotasks.cjs": 3034, "600-closures-basic.ts": 2349, + "6000-json-stringify-class-instances.ts": 7567, + "6001-json-stringify-js-class-instances.js": 4668, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, "6020-class-extends-function-constructor.js": 4300, @@ -5241,6 +5243,8 @@ "5995-array-dense-loop-boundaries.ts": 4171, "5996-rejections-between-macrotasks.cjs": 22172, "600-closures-basic.ts": 2534, + "6000-json-stringify-class-instances.ts": 12107, + "6001-json-stringify-js-class-instances.js": 7469, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, "6020-class-extends-function-constructor.js": 7500, From 8d94e5d99396a2a1fac9e949708a9aee56b2a12e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 18:34:09 -0500 Subject: [PATCH 2/2] Refuse RegExp and Promise class fields in JSON.stringify A Date field reads through the class's checked-dynamic property view and writes its toJSON text, so json-class-fields.ts compiled and matched Node while the diagnostics test expected a refusal. The Date cases move to 6000-json-stringify-class-instances.ts. RegExp and Promise fields read through the view but the runtime JSON traversal has no writer for them: a RegExp field threw at run time where Node writes {}, and a Promise field failed to link. Both are now refused at compile time naming the field, also inside a field's arrays, records and unions, and the fixture covers them. --- .../language-features/class-reflection.mdx | 2 +- .../src/frontend/lowering/builtins/json.ts | 50 ++++++++++++++++--- .../6000-json-stringify-class-instances.ts | 12 +++++ tests/diagnostics/json-class-fields.ts | 25 ++++++---- .../__snapshots__/json-class-fields.ts.txt | 27 ++++++---- 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/docs/content/limitations/language-features/class-reflection.mdx b/docs/content/limitations/language-features/class-reflection.mdx index 8b0738cc7..f2b6dc2c8 100644 --- a/docs/content/limitations/language-features/class-reflection.mdx +++ b/docs/content/limitations/language-features/class-reflection.mdx @@ -1 +1 @@ -- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. `JSON.stringify` writes Map and Set fields as `{}`, as Node.js does, and refuses classes with Date fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. +- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. `JSON.stringify` writes Date fields as their ISO text and Map and Set fields as `{}`, as Node.js does, and refuses classes with RegExp or Promise fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index c99a99c75..013b16e7c 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -168,7 +168,7 @@ export function lowerJsonMethodCall( argNode, `JSON.stringify of '${lowerer.fmt(value.type)}' values (field '${unreadable.field}' of class ` + `'${unreadable.className}' has type '${lowerer.fmt(unreadable.type)}', which the runtime JSON ` + - `traversal cannot read)`, + `traversal cannot ${unreadable.reason})`, ); } // Keep the original containers live while the runtime visits them: @@ -290,15 +290,47 @@ function lowerOptionalStringifyRoot( return { kind: "call", callee: helper, args: [value], type: resultT, loc }; } +/** True when a class field type holds a RegExp or a Promise outside a + * nested class instance (whose own fields are checked separately): Node + * writes both as {}, and the runtime JSON traversal has no writer for them. */ +function holdsUnwritable(lowerer: Lowerer, t: IrType, visiting: Set): boolean { + switch (t.kind) { + case "regex": + case "promise": + return true; + case "array": + return holdsUnwritable(lowerer, t.elem, visiting); + case "record": { + if (visiting.has(t.shapeId)) return false; + visiting.add(t.shapeId); + const shape = lowerer.shapes.get(t.shapeId); + return ( + (shape?.fields ?? []).some((field) => holdsUnwritable(lowerer, field.type, visiting)) || + (shape?.indexValue !== undefined && holdsUnwritable(lowerer, shape.indexValue, visiting)) + ); + } + case "union": { + if (visiting.has(t.unionId)) return false; + visiting.add(t.unionId); + return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => + holdsUnwritable(lowerer, arm, visiting), + ); + } + default: + return false; + } +} + /** The first class field reachable from a stringify input that the class's - * checked-dynamic property view cannot represent. The runtime JSON traversal + * checked-dynamic property view cannot represent, or that holds a RegExp or + * a Promise, which the runtime JSON traversal cannot write. The traversal * reads class instances through that view, so such a class is refused at - * compile time instead of throwing when the view is built. */ + * compile time instead of throwing when the view is built or written. */ function classFieldWithoutJsonView( lowerer: Lowerer, t: IrType, visiting: Set, -): { className: string; field: string; type: IrType } | null { +): { className: string; field: string; type: IrType; reason: "read" | "write" } | null { const getRecord = (id: string) => lowerer.shapes.get(id); const getUnion = (id: string) => lowerer.unions.get(id); switch (t.kind) { @@ -331,10 +363,16 @@ function classFieldWithoutJsonView( const info = lowerer.classes.get(t.className); for (const field of info?.def.fields ?? []) { if (!isClassOwnEnumerableFieldName(field.name)) continue; - if (!classDynViewSupported([field], getRecord, getUnion)) { + const unreadable = !classDynViewSupported([field], getRecord, getUnion); + if (unreadable || holdsUnwritable(lowerer, field.type, new Set())) { const className = info?.def.jsName ?? info?.decl?.name?.text ?? t.className.replace(/^%/, ""); - return { className, field: field.name, type: field.type }; + return { + className, + field: field.name, + type: field.type, + reason: unreadable ? "read" : "write", + }; } const found = classFieldWithoutJsonView(lowerer, field.type, visiting); if (found) return found; diff --git a/tests/corpus/6000-json-stringify-class-instances.ts b/tests/corpus/6000-json-stringify-class-instances.ts index a73168b40..cc72a0b82 100644 --- a/tests/corpus/6000-json-stringify-class-instances.ts +++ b/tests/corpus/6000-json-stringify-class-instances.ts @@ -129,3 +129,15 @@ h.payload = 7; console.log(JSON.stringify({ handlers: [h] })); const u: unknown = new Map([["a", 1]]); console.log(JSON.stringify(new Tagged()), JSON.stringify(u)); + +// Date fields write their toJSON text, also in nested instances. +class Schedule { + name = "weekly"; + next = new Date(Date.UTC(2024, 0, 1)); +} +class Calendar { + title = "team"; + schedules: Schedule[] = [new Schedule()]; +} +console.log(JSON.stringify(new Schedule())); +console.log(JSON.stringify({ calendar: new Calendar() })); diff --git a/tests/diagnostics/json-class-fields.ts b/tests/diagnostics/json-class-fields.ts index 81012c161..d4d3f8e73 100644 --- a/tests/diagnostics/json-class-fields.ts +++ b/tests/diagnostics/json-class-fields.ts @@ -1,15 +1,22 @@ // JSON.stringify reads class instances through their checked-dynamic -// property view. A class whose declared field has no representation in -// that view is refused at compile time, naming the field. -class Schedule { - name = "weekly"; - next = new Date(Date.UTC(2024, 0, 1)); +// property view. A class whose declared field holds a RegExp or a Promise, +// which the runtime JSON traversal cannot write, is refused at compile +// time, naming the field. +class Pattern { + name = "digits"; + source = /\d+/g; } -class Calendar { +class Job { + title = "fetch"; + pending = Promise.resolve(1); +} + +class Catalog { title = "team"; - schedules: Schedule[] = [new Schedule()]; + patterns: Pattern[] = [new Pattern()]; } -console.log(JSON.stringify(new Schedule())); -console.log(JSON.stringify({ calendar: new Calendar() })); +console.log(JSON.stringify(new Pattern())); +console.log(JSON.stringify(new Job())); +console.log(JSON.stringify({ catalog: new Catalog() })); diff --git a/tests/harness/__snapshots__/json-class-fields.ts.txt b/tests/harness/__snapshots__/json-class-fields.ts.txt index 568cfa47f..5e6c30fdf 100644 --- a/tests/harness/__snapshots__/json-class-fields.ts.txt +++ b/tests/harness/__snapshots__/json-class-fields.ts.txt @@ -1,13 +1,20 @@ -json-class-fields.ts:14:28 - error SC1090: JSON.stringify of 'Schedule' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet +json-class-fields.ts:20:28 - error SC1090: JSON.stringify of 'Pattern' values (field 'source' of class 'Pattern' has type 'RegExp', which the runtime JSON traversal cannot write) are not supported yet - 13 | - 14 | console.log(JSON.stringify(new Schedule())); - | ^~~~~~~~~~~~~~ - 15 | console.log(JSON.stringify({ calendar: new Calendar() })); + 19 | + 20 | console.log(JSON.stringify(new Pattern())); + | ^~~~~~~~~~~~~ + 21 | console.log(JSON.stringify(new Job())); -json-class-fields.ts:15:28 - error SC1090: JSON.stringify of '{ calendar: Calendar }' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet +json-class-fields.ts:21:28 - error SC1090: JSON.stringify of 'Job' values (field 'pending' of class 'Job' has type 'Promise', which the runtime JSON traversal cannot write) are not supported yet - 14 | console.log(JSON.stringify(new Schedule())); - 15 | console.log(JSON.stringify({ calendar: new Calendar() })); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 16 | \ No newline at end of file + 20 | console.log(JSON.stringify(new Pattern())); + 21 | console.log(JSON.stringify(new Job())); + | ^~~~~~~~~ + 22 | console.log(JSON.stringify({ catalog: new Catalog() })); + +json-class-fields.ts:22:28 - error SC1090: JSON.stringify of '{ catalog: Catalog }' values (field 'source' of class 'Pattern' has type 'RegExp', which the runtime JSON traversal cannot write) are not supported yet + + 21 | console.log(JSON.stringify(new Job())); + 22 | console.log(JSON.stringify({ catalog: new Catalog() })); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~ + 23 | \ No newline at end of file