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.
This commit is contained in:
Chris Tate
2026-10-29 19:47:53 -05:00
parent 7a9ad0140f
commit 7943812231
4 changed files with 124 additions and 51 deletions
@@ -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",
@@ -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<string>;
/** The asserted type's source spelling, for the throw messages. */
readonly text: string;
@@ -49,7 +53,7 @@ interface PartialTarget {
const NO_FIELDS: ReadonlySet<string> = 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<string> {
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",
@@ -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);
});
+14
View File
@@ -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 {};