mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user