Keep the fromEntries assertion bridge to entries without native arrays or records

Object.fromEntries(...) as T passed the entries through the
checked-dynamic bridge, which copies native arrays and records: an array
shared by two entries, or held elsewhere, became separate copies, and
writes through the asserted object no longer reached it. The bridge now
applies only when the entries' values are scalars or checked-dynamic
values, which pass by value or by identity; other entries keep the
exact-shape fence (SC2002). Corpus 6467 checks that entries held as
unknown keep their identity; diagnostics fixture
fromentries-aliased-entries.ts pins the fence.
This commit is contained in:
Chris Tate
2026-10-20 11:40:52 -05:00
parent 23355baa2c
commit f7287e30ff
5 changed files with 51 additions and 4 deletions
@@ -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 (
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/fromentries-aliased-entries.ts"
],
"diags": []
}
@@ -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);
@@ -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);
@@ -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