From 7943812231efd95688c86a38ce6df7c403ebc007 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 19:47:53 -0500 Subject: [PATCH] Keep omitted optional fields in the partial view's layout; save the receiver A partial record assertion's literal now keeps the asserted type's optional fields it omits, in their absent state, as an ordinary record of that type does: a read yields undefined, in answers false and a write adds the property, with no guard. Only an omitted required field is missing from the view, and only its writes are guarded, so a write to an absent optional field of an ordinary record of the same type no longer throws. The receiver of a guarded write is saved whenever its value is, so a right side that reassigns the receiver's variable no longer redirects the check and the store. --- .../frontend/lowering/expressions/updates.ts | 11 ++- .../frontend/lowering/partial-assertions.ts | 88 ++++++++++--------- .../record-partial-assertion-lowering.test.ts | 62 +++++++++++-- tests/corpus/record-partial-assertion.ts | 14 +++ 4 files changed, 124 insertions(+), 51 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 30ea81ae2..789fb4b25 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -908,13 +908,16 @@ export function fieldSetStmt( const refused = partialFieldWriteError(lowerer, target.shapeId, target.field, loc); if (refused !== null) { const obj = target.obj; - const stable = isSafeToRepeat(obj) - ? null - : lowerer.declareHiddenLocal("%partialWrite", obj.type); - const ref = stable ? varRef(stable.id, obj.type, loc) : obj; const saved = isSafeToRepeat(value) ? null : lowerer.declareHiddenLocal("%partialWriteValue", value.type); + // The receiver is saved whenever the value is: a right side can + // reassign the variable the receiver reads. + const stable = + isSafeToRepeat(obj) && saved === null + ? null + : lowerer.declareHiddenLocal("%partialWrite", obj.type); + const ref = stable ? varRef(stable.id, obj.type, loc) : obj; const written = saved ? varRef(saved.id, value.type, loc) : value; return { kind: "block", diff --git a/packages/compiler/src/frontend/lowering/partial-assertions.ts b/packages/compiler/src/frontend/lowering/partial-assertions.ts index 5f8ecc253..545f4b26b 100644 --- a/packages/compiler/src/frontend/lowering/partial-assertions.ts +++ b/packages/compiler/src/frontend/lowering/partial-assertions.ts @@ -4,19 +4,20 @@ * type may require more. Node erases the assertion: the value keeps the * literal's properties, and reading a field it never had yields undefined. * A record type has a fixed layout, so scriptc keeps the LITERAL's layout - * (shape A — the written fields, with the asserted type's field types) and + * (shape A — the written fields and the asserted type's optional fields, + * these in their absent state, with the asserted type's field types) and * hands the asserted type B a PARTIAL record view of it (record-views.ts): - * the value is a B whose record-source entry names A, and B fields A does - * not declare are absent. Reflection through the view is exact. + * the value is a B whose record-source entry names A, and the required B + * fields A does not declare are absent. Reflection through the view is + * exact. * * This module finds those sites and answers, per target shape, which - * fields some asserted literal leaves unwritten. The lowering consults it - * for member reads and writes, destructuring and `in`, so a read of an - * absent optional field yields undefined, as in Node, and a read of an - * absent required field throws the catchable TypeError (Node yields - * undefined — a documented divergence), instead of reaching a slot the - * view does not have. Computed once for the whole program, on first use, - * like the untyped-collection inference (js-collection-types.ts). */ + * required fields some asserted literal leaves unwritten. The lowering + * consults it for member reads and writes, destructuring and `in`, so a + * read of such a field throws the catchable TypeError (Node yields + * undefined — a documented divergence) instead of reaching a slot the view + * does not have. Computed once for the whole program, on first use, like + * the untyped-collection inference (js-collection-types.ts). */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; @@ -37,6 +38,9 @@ import { export interface PartialAssertionSite { readonly target: IrType & { kind: "record" }; readonly keys: readonly string[]; + /** The optional B fields the literal does not write, which the literal's + * layout keeps in their absent state. */ + readonly absent: readonly string[]; readonly missing: ReadonlySet; /** The asserted type's source spelling, for the throw messages. */ readonly text: string; @@ -49,7 +53,7 @@ interface PartialTarget { const NO_FIELDS: ReadonlySet = new Set(); -/** The fields some asserted literal leaves unwritten on shapes of +/** The required fields some asserted literal leaves unwritten on shapes of * `shapeId`; empty when no partial assertion produces that shape. */ export function partialAssertionFields(lowerer: Lowerer, shapeId: string): ReadonlySet { return partialTargetOf(lowerer, shapeId)?.missing ?? NO_FIELDS; @@ -109,26 +113,27 @@ export function partialAssertionSite( // map, which a view has no slots for. if (!shape.fields.some((f) => f.name === key)) return null; } - let required = false; + const absent: string[] = []; for (const field of shape.fields) { if (keys.includes(field.name)) continue; - // The view has no slot for any field the literal does not write, an - // optional one included. + // An omitted optional field keeps a slot in the literal's layout, in its + // absent state (lowerPartialAssertion), as an ordinary record of the + // asserted type does: reads yield undefined, `in` answers false and a + // write adds the property. Only a required field can be missing. + if (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0) { + absent.push(field.name); + continue; + } missing.add(field.name); - if (!(field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0)) - required = true; } - // Omitting only optional fields needs no view: the literal at the - // asserted type's layout holds their absent state. - if (!required) return null; - return { target, keys, missing, text: lowerer.checker.typeToString(targetTs) }; + if (missing.size === 0) return null; + return { target, keys, absent, missing, text: lowerer.checker.typeToString(targetTs) }; } /** The checked read of `field` through a value of `shapeId` that a partial - * assertion may have produced (undefined for an absent optional field, a - * TypeError for an absent required one), or null when the field is always - * present. `obj` is read twice (the presence test and the read), so it must - * be safe to repeat. */ + * assertion may have produced, or null when the field is always present. + * `obj` is read twice (the presence test and the read), so it must be safe + * to repeat. */ export function partialFieldRead( lowerer: Lowerer, obj: IrExpr, @@ -139,25 +144,17 @@ export function partialFieldRead( ): IrExpr | null { const target = partialTargetOf(lowerer, shapeId); if (target === null || !target.missing.has(field)) return null; - // An optional field the literal omits reads as undefined, as in Node. - const optional = - fieldType.kind === "union" && lowerer.armTag(fieldType.unionId, UNDEFINED_T) >= 0; return { kind: "ternary", cond: { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }, then: { kind: "recordGet", obj, shapeId, field, type: fieldType, loc }, - else_: optional - ? lowerer.coerceToExpected( - { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, - fieldType, - ) - : nodeThrowExpr( - 1, - "", - `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`, - fieldType, - loc, - ), + else_: nodeThrowExpr( + 1, + "", + `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`, + fieldType, + loc, + ), type: fieldType, loc, }; @@ -221,15 +218,24 @@ export function lowerPartialAssertion( const value = lowerer.coerceInto(prop, lowerer.lowerExprExpecting(initializer, type), type); return { name: key, type, value }; }); + for (const name of site.absent) { + const type = shape.fields.find((f) => f.name === name)!.type as IrType & { kind: "union" }; + fields.push({ + name, + type, + value: { kind: "fieldAbsent", unionId: type.unionId, type, loc }, + }); + } // A's identity is its name-sorted field list; its DECLARED order is the - // literal's, which JSON and Object.keys read through the view's capsule. + // literal's, which JSON and Object.keys read through the view's capsule, + // then the absent optional fields, which a later write appends. const shapeId = lowerer.shapes.intern( [...fields] .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) .map((f) => ({ name: f.name, type: f.type })), false, undefined, - [...site.keys], + [...site.keys, ...site.absent], ); const built: IrExpr = { kind: "recordLit", diff --git a/packages/compiler/test/record-partial-assertion-lowering.test.ts b/packages/compiler/test/record-partial-assertion-lowering.test.ts index 0ba5fb70d..3668031f0 100644 --- a/packages/compiler/test/record-partial-assertion-lowering.test.ts +++ b/packages/compiler/test/record-partial-assertion-lowering.test.ts @@ -115,11 +115,11 @@ test("a write of a field a partial assertion never wrote throws before storing", ]); }); -test("a read of an optional field a partial assertion omits yields undefined", async () => { +test("an optional field a partial assertion omits keeps an absent slot, read without a check", async () => { const nodes = await moduleNodes<{ kind: string; cond?: { kind: string; field?: string }; - else_?: { kind: string; fn?: string }; + fields?: { name: string; value: { kind: string } }[]; }>( `interface N { content: string; note?: string } function read(value: N): string | undefined { return value.note; } @@ -128,11 +128,17 @@ test("a read of an optional field a partial assertion omits yields undefined", a export {}; `, ); - const guard = nodes.find( - (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "note", + // The literal's layout holds the omitted optional field in its absent + // state, as an ordinary record of the asserted type does. + const literal = nodes.find( + (n) => n.kind === "recordLit" && n.fields?.some((f) => f.name === "note") === true, ); - expect(guard).toBeDefined(); - expect(guard!.else_?.fn).not.toBe("error.nodeThrow"); + expect(literal?.fields?.find((f) => f.name === "note")?.value.kind).toBe("fieldAbsent"); + expect( + nodes.some( + (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "note", + ), + ).toBe(false); }); test("a write of a field a partial assertion never wrote evaluates the value before the check", async () => { @@ -163,3 +169,47 @@ test("a write of a field a partial assertion never wrote evaluates the value bef expect(saved).toBeGreaterThanOrEqual(0); expect(saved).toBeLessThan(check); }); + +test("a write of an optional field a partial assertion omits needs no guard", async () => { + const nodes = await moduleNodes<{ kind: string; cond?: { kind: string; field?: string } }>( + `interface N { content: string; note?: string } + function write(target: N): void { target.note = "x"; } + write({} as N); + write({ content: "c" }); + export {}; + `, + ); + expect( + nodes.some((n) => n.kind === "if" && n.cond?.kind === "recordHas" && n.cond.field === "note"), + ).toBe(false); +}); + +test("a partial-field write saves its receiver when the right side may reassign it", async () => { + const nodes = await moduleNodes<{ + kind: string; + body?: { kind: string; localId?: string; cond?: { kind: string; field?: string } }[]; + }>( + `${TR}function value(): string { return "x"; } + let target: TR = { truncated: true } as TR; + target.content = value(); + export {}; + `, + ); + const body = nodes + .filter((n) => n.kind === "block") + .map((n) => n.body ?? []) + .find((stmts) => + stmts.some( + (s) => s.kind === "if" && s.cond?.kind === "recordHas" && s.cond.field === "content", + ), + ); + expect(body).toBeDefined(); + const receiver = body!.findIndex( + (s) => s.kind === "varDecl" && /%partialWrite(?!Value)/.test(s.localId ?? ""), + ); + const value = body!.findIndex( + (s) => s.kind === "varDecl" && s.localId?.includes("partialWriteValue") === true, + ); + expect(receiver).toBeGreaterThanOrEqual(0); + expect(receiver).toBeLessThan(value); +}); diff --git a/tests/corpus/record-partial-assertion.ts b/tests/corpus/record-partial-assertion.ts index e991299c4..8acc1b344 100644 --- a/tests/corpus/record-partial-assertion.ts +++ b/tests/corpus/record-partial-assertion.ts @@ -48,4 +48,18 @@ try { order.push("caught"); } console.log(order[0]); + +// An ordinary record of the asserted type keeps a slot for its absent +// optional field, so a write adds the property. +const plainNote: Note = { content: "c", truncated: false }; +plainNote.note = "added"; +console.log(plainNote.note, "note" in plainNote, JSON.stringify(plainNote)); + +// A right side that reassigns the receiver's variable: the original +// receiver takes the write. +let reassigned: Note = { content: "first", truncated: false, note: "n" }; +const firstNote = reassigned; +const otherNote: Note = { content: "other", truncated: true, note: "o" }; +reassigned.content = ((reassigned = otherNote), "changed"); +console.log(firstNote.content, otherNote.content, reassigned === otherNote); export {};