mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-28 06:53:42 +08:00
Stringify class instances with JSON.stringify (#1338)
This commit is contained in:
@@ -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 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.
|
||||
|
||||
@@ -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 ${unreadable.reason})`,
|
||||
);
|
||||
}
|
||||
// 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,100 @@ 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<string>): 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, 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 or written. */
|
||||
function classFieldWithoutJsonView(
|
||||
lowerer: Lowerer,
|
||||
t: IrType,
|
||||
visiting: Set<string>,
|
||||
): { 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) {
|
||||
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;
|
||||
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,
|
||||
reason: unreadable ? "read" : "write",
|
||||
};
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -3730,9 +3730,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(
|
||||
@@ -3741,7 +3742,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)
|
||||
);
|
||||
@@ -3851,8 +3852,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":
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6000-json-stringify-class-instances.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6001-json-stringify-js-class-instances.js"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/json-class-fields.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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<T> {
|
||||
value: T;
|
||||
constructor(value: T) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
class Registry {
|
||||
entries = new Map<string, number>([["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<number>([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));
|
||||
|
||||
// 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() }));
|
||||
@@ -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)] }));
|
||||
@@ -0,0 +1,22 @@
|
||||
// JSON.stringify reads class instances through their checked-dynamic
|
||||
// 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 Job {
|
||||
title = "fetch";
|
||||
pending = Promise.resolve(1);
|
||||
}
|
||||
|
||||
class Catalog {
|
||||
title = "team";
|
||||
patterns: Pattern[] = [new Pattern()];
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(new Pattern()));
|
||||
console.log(JSON.stringify(new Job()));
|
||||
console.log(JSON.stringify({ catalog: new Catalog() }));
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
|
||||
19 |
|
||||
20 | console.log(JSON.stringify(new Pattern()));
|
||||
| ^~~~~~~~~~~~~
|
||||
21 | console.log(JSON.stringify(new Job()));
|
||||
|
||||
json-class-fields.ts:21:28 - error SC1090: JSON.stringify of 'Job' values (field 'pending' of class 'Job' has type 'Promise<number>', which the runtime JSON traversal cannot write) are not supported yet
|
||||
|
||||
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 |
|
||||
@@ -2209,6 +2209,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,
|
||||
@@ -5265,6 +5267,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,
|
||||
|
||||
Reference in New Issue
Block a user