From 88f2061f628975de1aeb09aff2031f2aa273e62d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 00:31:25 -0500 Subject: [PATCH] Refuse JSON and dynamic copies of object literals with accessors An object literal with get/set accessors keeps the accessors' closures in reserved slots of its record. Since function values became convertible to checked-dynamic values, these records passed the JSON and conversion checks: JSON.stringify omitted the getter's property, as it omits function-valued ones, and a copy held as `any` or `unknown` read the getter's property as undefined and listed the slot's internal name among its keys. Node invokes the getter in both cases. Records with accessor slots are now outside the JSON-safe and dyn-convertible domains, so JSON.stringify of such a value and its conversion to `unknown` or `any` are refused at compile time. A literal written directly into an `any` slot keeps its accessors and is unchanged. --- packages/compiler/src/ir/ir.ts | 6 ++++ .../object-literal-accessor-values.ts.json | 6 ++++ .../object-literal-accessor-values.ts | 31 +++++++++++++++++++ .../object-literal-accessor-values.ts.txt | 24 ++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json create mode 100644 tests/diagnostics/object-literal-accessor-values.ts create mode 100644 tests/harness/__snapshots__/object-literal-accessor-values.ts.txt diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index b84c0594e..9a8f1121c 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3891,6 +3891,9 @@ function isJsonSafeAt( const shape = getRecord(t.shapeId); if (!shape) return false; if (recordTextCodecClass(shape) !== null) return false; + // Accessor slots are not function-valued properties: Node's + // JSON.stringify invokes the getter (see accessorSlotProp). + if (shapeHasAccessorSlots(shape)) return false; // The recursive knot: answer true and let the rest of the graph // decide (any unsafe constituent ends the traversal immediately). if (visiting.has(t.shapeId)) return true; @@ -4228,6 +4231,9 @@ function canBoxDynCompositeAt( const shape = getRecord(t.shapeId); if (!shape) return false; if (recordTextCodecClass(shape) !== null) return false; + // Accessor slots hold the getter and setter closures, not property + // values (see accessorSlotProp). + if (shapeHasAccessorSlots(shape)) return false; // Recursive shapes answer coinductively, like isJsonSafeType. if (visiting.has(t.shapeId)) return true; visiting.add(t.shapeId); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json new file mode 100644 index 000000000..08ae4c161 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/object-literal-accessor-values.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/object-literal-accessor-values.ts b/tests/diagnostics/object-literal-accessor-values.ts new file mode 100644 index 000000000..107ab1f69 --- /dev/null +++ b/tests/diagnostics/object-literal-accessor-values.ts @@ -0,0 +1,31 @@ +// Object literals with get/set accessors keep their closures in reserved +// slots, so their typed values have no JSON or checked-dynamic form: Node's +// JSON.stringify and dynamic reads invoke the getter, which neither a typed +// serializer nor a converted copy could do. + +function hold(value: unknown): unknown { + return value; +} + +const counter = { + count: 1, + get doubled(): number { + return 2; + }, +}; + +// JSON.stringify of the typed value would omit the getter's property. +console.log(JSON.stringify(counter)); + +// Converting the typed value to `unknown` or `any` would lose the getter. +hold(counter); +const viaAny: any = counter; +console.log(viaAny); + +// A literal written into an `any` slot directly keeps its accessor. +const direct: any = { + get value(): number { + return 3; + }, +}; +console.log(direct.value); diff --git a/tests/harness/__snapshots__/object-literal-accessor-values.ts.txt b/tests/harness/__snapshots__/object-literal-accessor-values.ts.txt new file mode 100644 index 000000000..89bc092aa --- /dev/null +++ b/tests/harness/__snapshots__/object-literal-accessor-values.ts.txt @@ -0,0 +1,24 @@ +object-literal-accessor-values.ts:18:28 - error SC1090: JSON.stringify of '{ get doubled(): number; count: number }' values (only number, string, boolean, records, arrays, unions of those, and 'unknown' stringify) are not supported yet + + 17 | // JSON.stringify of the typed value would omit the getter's property. + 18 | console.log(JSON.stringify(counter)); + | ^~~~~~~ + 19 | + +object-literal-accessor-values.ts:21:6 - error SC1101: converting typed values to 'unknown' is not supported yet + + 20 | // Converting the typed value to `unknown` or `any` would lose the getter. + 21 | hold(counter); + | ^~~~~~~ + 22 | const viaAny: any = counter; + + hint: numbers, strings, booleans, JSON-safe records/arrays/unions (a deep copy), functions, program class instances, and supported native handles convert into 'unknown' slots; functions, exact-class round trips, and handles preserve identity, while this value's type has no dynamic representation yet + +object-literal-accessor-values.ts:22:21 - error SC1101: converting typed values to 'unknown' is not supported yet + + 21 | hold(counter); + 22 | const viaAny: any = counter; + | ^~~~~~~ + 23 | console.log(viaAny); + + hint: numbers, strings, booleans, JSON-safe records/arrays/unions (a deep copy), functions, program class instances, and supported native handles convert into 'unknown' slots; functions, exact-class round trips, and handles preserve identity, while this value's type has no dynamic representation yet \ No newline at end of file