Spread unions with an absent arm into declared records; explain the optional-unknown delete refusal (#2639)

This commit is contained in:
Chris Tate
2026-10-30 09:36:38 -05:00
committed by GitHub
7 changed files with 249 additions and 12 deletions
@@ -3576,6 +3576,28 @@ export function conditionalSpreadOf(
return { cond: e.condition, props, whenTrue: trueCarries };
}
/** The record arms of a union spread source of the declared-shape merge,
* or null unless every arm is a non-tuple record or the null/undefined
* unit and at least one arm is a record. Unit arms contribute nothing:
* CopyDataProperties skips nullish sources. */
function declaredMergeUnionArms(
lowerer: Lowerer,
unionId: string,
): { tag: number; shapeId: string; shape: IrRecordShape }[] | null {
const def = lowerer.unions.get(unionId);
if (!def) return null;
const arms: { tag: number; shapeId: string; shape: IrRecordShape }[] = [];
for (let tag = 0; tag < def.arms.length; tag++) {
const arm = def.arms[tag]!;
if (arm.kind === "undefinedT" || arm.kind === "nullT") continue;
if (arm.kind !== "record") return null;
const armShape = lowerer.shapes.get(arm.shapeId);
if (!armShape || armShape.tuple) return null;
arms.push({ tag, shapeId: arm.shapeId, shape: armShape });
}
return arms.length > 0 ? arms : null;
}
/** `{ ...idx, ...idx2 }` typed a DECLARED shape (no index signature) — the
* defaults-merge idiom over runtime-keyed sources (`{ ...fromEntries(a),
* ...fromEntries(b) }` typed AppConfig). Lowers to ONE interned helper
@@ -3591,7 +3613,12 @@ export function conditionalSpreadOf(
* untyped copy would store the lie) — and a key naming NO declared field
* is DROPPED (the shape cannot represent it; Node keeps it invisibly —
* divergence 68). Later contributors overwrite earlier ones
* (last-write-wins). Fixed, hybrid, and pure index records may contribute. */
* (last-write-wins). Fixed, hybrid, and pure index records may contribute,
* and so may a union of those records with the null/undefined unit: each
* record arm copies through its own shape's helper behind its tag test,
* a unit arm copies nothing (CopyDataProperties skips nullish sources),
* and the union is held in a hidden local, so it still evaluates once, in
* order. */
function lowerDeclaredSpreadMerge(
lowerer: Lowerer,
expr: ts.ObjectLiteralExpression,
@@ -3603,6 +3630,11 @@ function lowerDeclaredSpreadMerge(
value: IrExpr;
shapeId: string;
shape?: IrRecordShape;
/** A union source (`{ ...model.compat }` of `R | undefined`): each
* record arm copies through its own shape's helper behind its tag
* test, and a null or undefined arm copies nothing (CopyDataProperties
* skips nullish sources). */
arms?: { tag: number; shapeId: string; shape: IrRecordShape }[];
}
const srcs: Src[] = [];
for (const prop of expr.properties.nodes) {
@@ -3612,6 +3644,14 @@ function lowerDeclaredSpreadMerge(
srcs.push({ value, shapeId: "dyn" });
continue;
}
if (value.type.kind === "union") {
const arms = declaredMergeUnionArms(lowerer, value.type.unionId);
if (arms) {
for (const arm of arms) fenceAccessorSpreadSource(lowerer, prop, arm.shape);
srcs.push({ value, shapeId: "union", arms });
continue;
}
}
const srcShape =
value.type.kind === "record" ? lowerer.shapes.get(value.type.shapeId) : undefined;
if (value.type.kind !== "record" || !srcShape || srcShape.tuple) {
@@ -3763,12 +3803,11 @@ function lowerDeclaredSpreadMerge(
loc,
},
];
for (const source of srcs) {
const mergeHelper = (source: Src, sourceType: IrType): string => {
const key = `declmerge-source:${type.shapeId}:${source.shapeId}`;
let helper = lowerer.valueHelpers.get(key);
if (!helper) {
helper = keyedHelperName(lowerer, "%rec.declmerge", key);
const sourceType = source.value.type;
const destination = varRef("out.0", type, loc);
const receiver = varRef("source.0", sourceType, loc);
const locals: IrLocal[] = [
@@ -3952,6 +3991,62 @@ function lowerDeclaredSpreadMerge(
// leave an entry that a later literal could reuse without a body.
lowerer.valueHelpers.set(key, helper);
}
return helper;
};
for (const source of srcs) {
if (source.arms && source.value.type.kind === "union") {
// Evaluate the union once, then copy the running arm's own keys.
const unionType = source.value.type;
const held = lowerer.declareHiddenLocal("%declMergeSource", unionType);
const heldRef = varRef(held.id, unionType, loc);
stmts.push({ kind: "varDecl", localId: held.id, init: source.value, loc });
for (const arm of source.arms) {
const armType: IrType = { kind: "record", shapeId: arm.shapeId };
const helper = mergeHelper(
{ value: heldRef, shapeId: arm.shapeId, shape: arm.shape },
armType,
);
stmts.push({
kind: "if",
cond: {
kind: "unionIsTag",
unionId: unionType.unionId,
tag: arm.tag,
negated: false,
value: heldRef,
type: BOOL,
loc,
},
then: [
{
kind: "exprStmt",
expr: {
kind: "call",
callee: helper,
args: [
outRef,
{
kind: "unionNarrow",
unionId: unionType.unionId,
tag: arm.tag,
value: heldRef,
type: armType,
loc,
},
],
type,
loc,
},
loc,
},
],
else_: null,
loc,
});
}
continue;
}
const helper = mergeHelper(source, source.value.type);
stmts.push({
kind: "exprStmt",
expr: { kind: "call", callee: helper, args: [outRef, source.value], type, loc },
@@ -7928,13 +7928,52 @@ export function lowerTry(lowerer: Lowerer, stmt: ts.TryStatement): IrStmt {
};
}
/** The refusal for `delete` of an optional `unknown`/`any` field, whose
* slot is the checked-dynamic one and so has no ABSENT state to write. */
const OPTIONAL_CHECKED_DYN_DELETE =
"'delete' of optional 'unknown'/'any' fields (the checked-dynamic slot has no absent state yet — an omitted such field already reads as present)";
/** TypeScript's declaration marks property `name` of `receiver` optional
* (`?:`), in every union constituent. A mapped IR type cannot tell an
* optional `unknown`/`any` field from a required one — both ride the
* checked-dynamic slot — so a refused `delete`'s real reason comes from
* the declaration. */
function declaredOptionalProperty(
lowerer: Lowerer,
receiver: ts.Expression,
name: string,
): boolean {
const receiverType = lowerer.typeOf(receiver);
const parts =
receiverType.flags & ts.TypeFlags.Union ? constituentTypes(receiverType) : [receiverType];
return parts.every((part) => {
const property = lowerer.checker.getPropertyOfType(part, name);
return property !== undefined && (property.flags & ts.SymbolFlags.Optional) !== 0;
});
}
/** `type` — a record or a union whose every arm is a non-tuple record —
* declares `name` in the checked-dynamic slot, which has no ABSENT state:
* an optional such field cannot be removed by a `delete`. */
function declaresCheckedDynField(lowerer: Lowerer, type: IrType, name: string): boolean {
if (type.kind === "record") {
const shape = lowerer.shapes.get(type.shapeId);
if (!shape || shape.tuple) return false;
return shape.fields.find((f) => f.name === name)?.type.kind === "dyn";
}
if (type.kind !== "union") return false;
const arms = lowerer.unions.get(type.unionId)?.arms ?? [];
return arms.length > 0 && arms.every((arm) => declaresCheckedDynField(lowerer, arm, name));
}
/** Statement-position `delete`: process.env keys → process.envUnset
* (unsetenv), pure `Record<string, T>` keys → recordKeyDelete (the
* overflow Map delete), declared OPTIONAL fields → the field's ABSENT
* state (the slot reads undefined and no longer counts as an own
* property). Everything else
* fences with the honest reason — a required field is a struct slot no
* runtime can remove. */
* runtime can remove, and an optional `unknown`/`any` field maps to the
* checked-dynamic slot, which has no ABSENT state yet. */
function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrStmt {
const loc = locOf(expr);
let target: ts.Expression = expr.expression;
@@ -8083,6 +8122,21 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
loc,
};
}
// The constant property key of the target (dot access or a literal
// bracket), when there is one.
const constantKey = ts.isPropertyAccessExpression(target)
? target.name.text
: ts.isStringLiteral(target.argumentExpression)
? target.argumentExpression.text
: null;
// An OPTIONAL `unknown`/`any` field rides the checked-dynamic slot,
// which has no ABSENT state yet (an omitted such field already reads as
// present), so it cannot delete. Name that reason instead of calling
// the field required or blaming the receiver.
const optionalCheckedDynDelete =
constantKey !== null &&
declaresCheckedDynField(lowerer, obj.type, constantKey) &&
declaredOptionalProperty(lowerer, target.expression, constantKey);
if (obj.type.kind === "record") {
const shape = lowerer.shapes.get(obj.type.shapeId);
if (shape?.indexValue && shape.fields.length === 0 && !shape.tuple) {
@@ -8099,14 +8153,9 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
// while a later write makes it present again. Constant keys only
// (dot access or a literal bracket); required fields keep the honest
// fence below.
const fieldName = ts.isPropertyAccessExpression(target)
? target.name.text
: ts.isStringLiteral(target.argumentExpression)
? target.argumentExpression.text
: null;
const field =
fieldName !== null && !shape?.tuple
? shape?.fields.find((f) => f.name === fieldName)
constantKey !== null && !shape?.tuple
? shape?.fields.find((f) => f.name === constantKey)
: undefined;
if (field) {
const absent = lowerer.absentFieldValue(field.type, loc);
@@ -8133,12 +8182,18 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
"'delete' of hybrid index-signature keys (declared struct slots and overflow entries answer differently — model deletable dynamic keys with a pure Record<string, T>)",
);
}
if (optionalCheckedDynDelete) {
lowerer.unsupported("SC1090", expr, OPTIONAL_CHECKED_DYN_DELETE);
}
lowerer.unsupported(
"SC1090",
expr,
"'delete' of required record fields (a monomorphic shape cannot remove its slot — only optional fields delete, becoming the undefined arm)",
);
}
if (optionalCheckedDynDelete) {
lowerer.unsupported("SC1090", expr, OPTIONAL_CHECKED_DYN_DELETE);
}
lowerer.unsupported(
"SC1090",
expr,
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/spread-union-declared-merge.ts"
],
"diags": []
}
@@ -0,0 +1,39 @@
// A union source spread beside a runtime-keyed source into a declared record
// (`{ ...model.compat, ...config.compat }`): a record arm copies its own
// keys, a null or undefined arm copies nothing, and the source evaluates
// once, in order. Keys print sorted: key order through the declared merge
// is a separate, pre-existing limitation.
interface Compat {
imageInput?: string;
sendImageOptInHeader?: string;
maxTokensField?: string;
}
type Small = { imageInput?: string; sendImageOptInHeader?: string };
function merge(a: Small | undefined, b: Record<string, unknown>): Compat {
return { ...a, ...b };
}
function show(c: Compat): string {
return [c.imageInput, c.sendImageOptInHeader, c.maxTokensField, "imageInput" in c, Object.keys(c).sort().join(",")].join(" ");
}
console.log(show(merge({ imageInput: "base64", sendImageOptInHeader: "yes" }, { maxTokensField: "max_tokens", imageInput: "url" })));
console.log(show(merge({ sendImageOptInHeader: "no" }, {})));
console.log(show(merge(undefined, { maxTokensField: "max_tokens", imageInput: "url" })));
console.log(show(merge(undefined, {})));
// Several record arms and a null arm: each arm copies its own keys.
type Other = { maxTokensField: string; extra?: string };
function mergeArms(a: Small | Other | null, b: Record<string, unknown>): Compat {
return { ...b, ...a };
}
console.log(show(mergeArms({ maxTokensField: "own" }, { maxTokensField: "base", imageInput: "i" })));
console.log(show(mergeArms({ imageInput: "own" }, { maxTokensField: "base", imageInput: "i" })));
console.log(show(mergeArms(null, { sendImageOptInHeader: "h" })));
// The source evaluates once, in order.
let calls = 0;
function source(): Small | undefined {
calls++;
return calls > 1 ? { imageInput: "second" } : undefined;
}
console.log(show(merge(source(), {})), show(merge(source(), { imageInput: "later" })), calls);
export {};
+27
View File
@@ -6,3 +6,30 @@ type Hybrid = { base: string; [k: string]: string };
const h: Hybrid = { base: "b", extra: "e" };
delete h["extra"];
console.log(h.base);
// An OPTIONAL checked-dynamic field (`unknown`/`any`) has no ABSENT state
// for a delete to write — the slot cannot represent "no property", and an
// omitted one already reads as present — so the refusal names that reason
// instead of calling the optional field required.
interface Dyn {
content: string[];
structured?: unknown;
}
const d: Dyn = { content: [] };
delete d.structured;
// The same fence on a union of records: every arm declares the field
// optional and checked-dynamic.
interface DynA {
content: string[];
structured?: unknown;
extra?: any;
}
interface DynB {
content: string[];
structured?: unknown;
}
function drop(u: DynA | DynB): void {
delete u.structured;
}
drop(d);
@@ -3,4 +3,18 @@ delete-fences.ts:7:1 - error SC1090: 'delete' of hybrid index-signature keys (de
6 | const h: Hybrid = { base: "b", extra: "e" };
7 | delete h["extra"];
| ^~~~~~~~~~~~~~~~~
8 | console.log(h.base);
8 | console.log(h.base);
delete-fences.ts:19:1 - error SC1090: 'delete' of optional 'unknown'/'any' fields (the checked-dynamic slot has no absent state yet — an omitted such field already reads as present) are not supported yet
18 | const d: Dyn = { content: [] };
19 | delete d.structured;
| ^~~~~~~~~~~~~~~~~~~
20 |
delete-fences.ts:33:3 - error SC1090: 'delete' of optional 'unknown'/'any' fields (the checked-dynamic slot has no absent state yet — an omitted such field already reads as present) are not supported yet
32 | function drop(u: DynA | DynB): void {
33 | delete u.structured;
| ^~~~~~~~~~~~~~~~~~~
34 | }
@@ -0,0 +1 @@
{ "plain": 4200, "san": 72000 }