Throw Node's TypeError for member reads of array holes in wide unions (#1806)

This commit is contained in:
Chris Tate
2026-10-22 06:35:04 -05:00
committed by GitHub
4 changed files with 100 additions and 6 deletions
@@ -985,11 +985,41 @@ export function runtimeOptionalReceiverRead(
loc,
};
}
// An array hole in a slot with more than two arms fails the member
// access with Node's TypeError before the checked extraction runs.
const holeGuarded = (extracted: IrExpr): IrExpr => {
if (local.type.kind !== "union") return extracted;
const holeTag = lowerer.armTag(local.type.unionId, UNDEFINED_T);
if (holeTag < 0) return extracted;
return {
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId: local.type.unionId,
tag: holeTag,
negated: false,
value: varRef(local.id, local.type, loc),
type: BOOL,
loc,
},
then: nodeThrowExpr(
1,
"",
runtimeOptionalReceiverMessage(expr, property),
extracted.type,
loc,
),
else_: extracted,
type: extracted.type,
loc,
};
};
if (narrowed.kind === "object") {
const view = checkedClassAssertion(lowerer, varRef(local.id, local.type, loc), narrowed, loc);
// Preserve the member-specific missing-value error on the two-arm
// path below; mixed storage also needs to validate its concrete class.
if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2) return view;
if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2)
return holeGuarded(view);
}
if (narrowed.kind === "record" && lowerer.armTag(local.type.unionId, narrowed) < 0) {
const hosted = hostedStorageView(lowerer, varRef(local.id, local.type, loc), narrowed, loc);
@@ -1014,7 +1044,9 @@ export function runtimeOptionalReceiverRead(
"a direct read from a runtime-optional capture with multiple value arms",
);
}
return lowerer.checkedNarrowCall(helper, varRef(local.id, local.type, loc), narrowed, loc);
return holeGuarded(
lowerer.checkedNarrowCall(helper, varRef(local.id, local.type, loc), narrowed, loc),
);
}
// A predicate can strengthen a record's fields or narrow a base class
// to a subclass without changing the slot's stored value arm. Extract
@@ -1029,10 +1061,7 @@ export function runtimeOptionalReceiverRead(
"a runtime-optional receiver without a representable value arm",
);
}
const message =
property === null
? `${expr.text} is not a function`
: `Cannot read properties of undefined (reading '${property}')`;
const message = runtimeOptionalReceiverMessage(expr, property);
return lowerer.maybeNarrow(
{
kind: "ternary",
@@ -1061,6 +1090,14 @@ export function runtimeOptionalReceiverRead(
);
}
/** Node's TypeError message for a member read (`property`) or a call
* (`property` null) through an undefined runtime-optional receiver. */
function runtimeOptionalReceiverMessage(expr: ts.Identifier, property: string | null): string {
return property === null
? `${expr.text} is not a function`
: `Cannot read properties of undefined (reading '${property}')`;
}
export function runtimeOptionalTrueIds(lowerer: Lowerer, node: ts.Expression): IrLocal[] {
return runtimeOptionalGuardIds(lowerer, node, true);
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7280-named-reads-hole-receivers.ts"
],
"diags": []
}
@@ -0,0 +1,50 @@
// Named reads and method calls through a parameter narrowed to one arm of a
// wide union, when an array hole reaches the narrowed branch.
type Bag = number | string | number[] | { a?: number; f?: () => string };
function isBag(value: Bag): value is { a?: number; f?: () => string } {
return !Array.isArray(value) && typeof value !== "string" && typeof value !== "number";
}
function peek(value: Bag): string {
if (isBag(value)) return typeof value.a;
return "-";
}
function call(value: Bag): string {
if (isBag(value)) return value.f?.() ?? "none";
return "-";
}
class Shape {
side: number;
constructor(side: number) {
this.side = side;
}
area(): number {
return this.side * this.side;
}
}
type Item = Shape | string | number;
function area(item: Item): number {
if (item instanceof Shape || (typeof item !== "string" && typeof item !== "number")) return item.area();
return 0;
}
const bags: Bag[] = [{ a: 1, f: () => "f" }, "s", [1]];
bags.length = 4;
const items: Item[] = [new Shape(2), "x"];
items.length = 3;
const out: string[] = [];
for (let i = 0; i < bags.length; i++) {
for (const which of [0, 1]) {
try {
out.push(which === 0 ? peek(bags[i]) : call(bags[i]));
} catch (e) {
out.push(e instanceof TypeError ? e.message : "other");
}
}
}
for (let i = 0; i < items.length; i++) {
try {
out.push(String(area(items[i])));
} catch (e) {
out.push(e instanceof TypeError ? e.message : "other");
}
}
console.log(out.join("\n"));
@@ -0,0 +1 @@
{ "plain": 4700, "san": 2400 }