From 834bccdcd0cdc272eb83513eb3b03ade112b42c9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 04:01:39 -0500 Subject: [PATCH] Keep records cast from unknown[] elements to dictionary types as live views --- .../record-identity-and-property-order.mdx | 2 +- .../limitations/runtime-type-checks.mdx | 2 + .../src/frontend/lowering/class-protocols.ts | 75 +++++++++++++++---- .../record-unknown-dictionary-views.ts.json | 6 ++ .../corpus/record-unknown-dictionary-views.ts | 37 +++++++++ .../record-unknown-dictionary-views.ts.json | 1 + tests/harness/dyncheck.test.ts | 34 +++++++++ 7 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json create mode 100644 tests/corpus/record-unknown-dictionary-views.ts create mode 100644 tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx index fc0b2be92..c76bf068a 100644 --- a/docs/content/limitations/record-identity-and-property-order.mdx +++ b/docs/content/limitations/record-identity-and-property-order.mdx @@ -4,7 +4,7 @@ Empty record layouts are also snapshots because they have no property storage. `scriptc coverage` and `scriptc build` report `SC6001` where a copied value is then written or compared, whether the record was passed as an argument, stored in a typed binding, asserted with `as`, assigned to a typed binding later, or stored as an element of a typed array literal. Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage. -A record or class instance converted to a string-keyed dictionary type such as `Record`, directly or through a `{}`, `object` or `unknown` binding or parameter, keeps its identity when the program writes through, compares or inspects values of that dictionary type. +A record or class instance converted to a string-keyed dictionary type such as `Record`, directly, through a `{}`, `object` or `unknown` binding or parameter, or as an element of a constant `unknown[]` array that holds such records, keeps its identity when the program writes through, compares or inspects values of that dictionary type. Every dictionary type with the same value type then uses checked dynamic storage, which is slower than a native dictionary. Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js. These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects. diff --git a/docs/content/limitations/runtime-type-checks.mdx b/docs/content/limitations/runtime-type-checks.mdx index f6ead482b..095f3d977 100644 --- a/docs/content/limitations/runtime-type-checks.mdx +++ b/docs/content/limitations/runtime-type-checks.mdx @@ -1,6 +1,8 @@ Casts from values whose type is only known at runtime validate the value. For example, `JSON.parse(s) as Config` throws a catchable error if the data does not match `Config`, identifying the offending path, such as `expected number at $.port, got string`. A TypeScript assertion alone does not perform this validation when run under Node.js. +A record or class instance cast from `unknown` to a dictionary type such as `Record` keeps its identity, and the cast does not validate it. +Reading a value that does not match the dictionary's value type throws a `TypeError`, such as `expected number at $, got string`, where Node.js returns the value. Casts between program classes are checked too: casting an instance to a class it does not extend throws a `TypeError`, where Node.js reads the members structurally. `SC6004` reports calls that pass such an instance to a function that casts it. diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 76a9b7f95..dce1bc191 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -973,15 +973,53 @@ export class ClassProtocols { callsNamed: (name: string) => readonly (ts.CallExpression | ts.NewExpression)[], ): boolean { if (this.isProgramClassInstance(type) || this.isRecordWithFields(type)) return true; - if (!isDynamicView(this.checker, type) || !ts.isIdentifier(source)) return false; + if (!isDynamicView(this.checker, type)) return false; + const holdsObjectType = (held: ts.Type | undefined): boolean => + held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held)); + const holdsObject = (expr: ts.Expression): boolean => + holdsObjectType(attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr)))); + const constInitializer = (name: ts.Identifier): ts.Expression | undefined => { + const decl = attempt(() => this.checker.getSymbolAtLocation(name))?.valueDeclaration; + return decl !== undefined && + ts.isVariableDeclaration(decl) && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 + ? decl.initializer + : undefined; + }; + if (ts.isElementAccessExpression(source)) { + // An element of a constant `unknown[]` binding holds what the + // binding's initializer stores: the elements of an array of records + // or class instances, or what a `map` callback returns. + let base = source.expression; + while (ts.isParenthesizedExpression(base)) base = base.expression; + const initializer = ts.isIdentifier(base) ? constInitializer(base) : undefined; + if (initializer === undefined) return false; + const array = attempt(() => this.checker.getTypeAtLocation(initializer)); + if ( + array !== undefined && + this.checker.isArrayType(array) && + holdsObjectType(this.checker.getTypeArguments(array as ts.TypeReference)[0]) + ) + return true; + let call = initializer; + while (ts.isParenthesizedExpression(call)) call = call.expression; + if ( + !ts.isCallExpression(call) || + !ts.isPropertyAccessExpression(call.expression) || + call.expression.name.text !== "map" + ) + return false; + const callback = call.arguments.nodes[0]; + return ( + callback !== undefined && + (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) && + functionResults(callback).some(holdsObject) + ); + } + if (!ts.isIdentifier(source)) return false; const decl = attempt(() => this.checker.getSymbolAtLocation(source))?.valueDeclaration; if (decl === undefined) return false; - const holdsObject = (expr: ts.Expression): boolean => { - const held = attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr))); - return ( - held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held)) - ); - }; if (ts.isParameter(decl)) { // A parameter holds what its callers pass: the calls of the function // by name whose signature resolves to it. @@ -1000,13 +1038,8 @@ export class ClassProtocols { ); }); } - return ( - ts.isVariableDeclaration(decl) && - decl.initializer !== undefined && - ts.isVariableDeclarationList(decl.parent) && - (decl.parent.flags & ts.NodeFlags.Const) !== 0 && - holdsObject(decl.initializer) - ); + const initializer = constInitializer(source); + return initializer !== undefined && holdsObject(initializer); } /** A program object type with declared properties that is not a class @@ -1371,6 +1404,20 @@ const IDENTITY_METHODS = new Set([ "includes", ]); +/** The values a function expression answers: an arrow's expression body, + * or the operands of its own return statements. */ +function functionResults(fn: ts.ArrowFunction | ts.FunctionExpression): ts.Expression[] { + if (!ts.isBlock(fn.body)) return [fn.body]; + const results: ts.Expression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) results.push(node.expression); + ts.forEachChild(node, visit); + }; + ts.forEachChild(fn.body, visit); + return results; +} + /** An expression that denotes an object that already exists, not a fresh * literal or call result. */ function isExistingObject(node: ts.Expression): boolean { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json new file mode 100644 index 000000000..2154966eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-unknown-dictionary-views.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-unknown-dictionary-views.ts b/tests/corpus/record-unknown-dictionary-views.ts new file mode 100644 index 000000000..0040dea45 --- /dev/null +++ b/tests/corpus/record-unknown-dictionary-views.ts @@ -0,0 +1,37 @@ +// Records stored as `unknown` array elements and cast to a dictionary type +// keep their identity: writes through the view reach the record, and reads +// of fields of the dictionary's value type answer the record's values. The +// elements come from map(), an array literal and an array of records. +type Stats = { hits: number; misses: number; name: string }; +const stats: Stats[] = [ + { hits: 1, misses: 0, name: "a" }, + { hits: 5, misses: 2, name: "b" }, +]; + +const mapped: unknown[] = stats.map((s) => s as unknown); +const first = mapped[0] as Record; +first["hits"] = first["hits"]! + 1; +console.log(stats[0]!.hits, (first as unknown) === stats[0], first["misses"]); + +const listed: unknown[] = [stats[1]]; +const second = listed[0] as Record; +second["misses"] = second["misses"]! * 10; +console.log(stats[1]!.misses, second["hits"]); + +const copied: unknown[] = stats; +const again = copied[1] as Record; +again["hits"] = 50; +console.log(stats[1]!.hits, again === second); + +const returned: unknown[] = stats.map((s) => { + return s as unknown; +}); +const third = returned[0] as Record; +third["misses"] = 7; +console.log(stats[0]!.misses, third === first); + +function bump(view: Record, key: "hits" | "misses"): void { + view[key] = view[key]! + 100; +} +bump(mapped[1] as Record, "hits"); +console.log(stats[1]!.hits, JSON.stringify(stats)); diff --git a/tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json b/tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json new file mode 100644 index 000000000..d2b66ce43 --- /dev/null +++ b/tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 53000 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 97def0cc6..3c04f24d2 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -209,6 +209,40 @@ console.log("unreachable", c.host); expect(r.stderr).toContain("Uncaught TypeError: expected number at $.port, got string"); }); + test("a record cast from unknown to a dictionary throws at its mismatched read", async () => { + // An element of an `unknown[]` keeps the record's identity, as an + // `unknown` binding does: the write lands, and only the read of a + // field the dictionary's value type does not admit throws. + const r = await compileAndRun( + "dictionary-view-mismatch", + `type Stats = { hits: number; name: string }; +const stats: Stats[] = [{ hits: 1, name: "a" }, { hits: 1, name: "b" }]; +function attempt(label: string, view: Record): void { + view["hits"] = view["hits"]! + 1; + try { + console.log(label, view["name"]); + } catch (error) { + console.log(label, (error as Error).name, (error as Error).message); + } +} +const mapped: unknown[] = stats.map((s) => s as unknown); +attempt("mapped", mapped[0] as Record); +const listed: unknown[] = [stats[1]]; +attempt("listed", listed[0] as Record); +console.log(stats[0]!.hits, stats[1]!.hits); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "mapped TypeError expected number at $, got string", + "listed TypeError expected number at $, got string", + "2 2", + "", + ].join("\n"), + ); + }); + test("schema-directed JSON.parse declines to the checked route with identical errors", async () => { // `JSON.parse(text) as T` for plain record/array targets parses in one // pass and reruns the checked-dynamic route whenever it declines, so