diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 0397497a8..ef6a30c03 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -1194,9 +1194,11 @@ function classInstanceOrAbsent(lowerer: Lowerer, type: IrType): boolean { * fresh object nothing else references, so the checked-dynamic bridge, * which keeps keys and their order the same way `JSON.parse(text) as T` * does, cannot be told apart from the original object; a value outside - * T throws the catchable TypeError of every checked cast. Other sources - * may be aliased, where a rebuilt object would sever mutation, so they - * keep the exact-shape fence. */ + * T throws the catchable TypeError of every checked cast. The entries' + * values must be scalars or checked-dynamic values, which the bridge + * passes by value or by identity; native arrays and records would be + * copied. Other sources may be aliased, where a rebuilt object would sever + * mutation, so they keep the exact-shape fence. */ function freshEntriesRecordAssertion( lowerer: Lowerer, expr: ts.AsExpression | ts.TypeAssertion, @@ -1208,6 +1210,10 @@ function freshEntriesRecordAssertion( const dest = lowerer.shapes.get(target.shapeId); if (!source?.indexValue || source.tuple || source.fields.length > 0) return null; if (!dest || dest.tuple || dest.indexValue || shapeHasAccessorSlots(dest)) return null; + // Native arrays and records in the entries would be copied on the way + // through the checked-dynamic bridge, severing their identity: only + // slots of scalars or checked-dynamic values pass. + if (extractionCopiesReference(lowerer, source.indexValue)) return null; let operand: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(operand)) operand = operand.expression; if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fromentries-aliased-entries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fromentries-aliased-entries.ts.json new file mode 100644 index 000000000..7388f2b71 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fromentries-aliased-entries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/fromentries-aliased-entries.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6467-fromentries-record-assertion.ts b/tests/corpus/6467-fromentries-record-assertion.ts index 1496292b3..cf8f1c1ff 100644 --- a/tests/corpus/6467-fromentries-record-assertion.ts +++ b/tests/corpus/6467-fromentries-record-assertion.ts @@ -1,6 +1,6 @@ // `Object.fromEntries(...) as T` for a declared record T keeps exactly // the keys the entries created, in their order, as Node keeps the object -// itself. +// itself, as long as the entries hold scalars or checked-dynamic values. type Tags = { "error.type": string; "error.msg": string; "error.cause"?: string }; function scrub(input: string): string { @@ -27,3 +27,17 @@ const p = Object.fromEntries([ ["z", 3], ]) as Point; console.log(p, p.x + p.y, Object.keys(p), JSON.stringify(p)); + +// Entries held as unknown keep their identity: the object's members are +// the same array and record, and writes through either reach both. +type Holder = { list: number[]; point: { x: number } }; +const list = [1, 2]; +const point = { x: 1 }; +const entries: [string, unknown][] = [ + ["list", list], + ["point", point], +]; +const held = Object.fromEntries(entries) as Holder; +held.list.push(3); +held.point.x = 5; +console.log(list.length, point.x, held.list === list, held.point === point); diff --git a/tests/diagnostics/fromentries-aliased-entries.ts b/tests/diagnostics/fromentries-aliased-entries.ts new file mode 100644 index 000000000..ee5cc5338 --- /dev/null +++ b/tests/diagnostics/fromentries-aliased-entries.ts @@ -0,0 +1,13 @@ +// `Object.fromEntries(...) as T` keeps the object only when the entries' +// values are scalars or checked-dynamic values. Native arrays and records +// in the entries would be copied, severing their identity, so the +// assertion keeps the exact-shape fence (corpus 6467 covers the cases that +// lower). +type Scores = { a: number[]; b: number[] }; +const shared = [10]; +const scores = Object.fromEntries([ + ["a", shared], + ["b", shared], +]) as Scores; +scores.a.push(11); +console.log(shared.length, scores.b.length); diff --git a/tests/harness/__snapshots__/fromentries-aliased-entries.ts.txt b/tests/harness/__snapshots__/fromentries-aliased-entries.ts.txt new file mode 100644 index 000000000..7b0f02266 --- /dev/null +++ b/tests/harness/__snapshots__/fromentries-aliased-entries.ts.txt @@ -0,0 +1,8 @@ +fromentries-aliased-entries.ts:8:16 - error SC2002: record shapes must match exactly or width-coerce: expected '{ a: number[]; b: number[] }', got '{ [key: string]: number[] }' — 'a' is not a declared field of the source, and the source's index signature could hold it at runtime (a completed undefined would drop that value) + + 7 | const shared = [10]; + 8 | const scores = Object.fromEntries([ + | ^~~~~~~~~~~~~~~~~~~~ + 9 | ["a", shared], + + hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead \ No newline at end of file