diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 2e3dd6140..786f3a337 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -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); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7280-named-reads-hole-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7280-named-reads-hole-receivers.ts.json new file mode 100644 index 000000000..febb008f7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7280-named-reads-hole-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7280-named-reads-hole-receivers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7280-named-reads-hole-receivers.ts b/tests/corpus/7280-named-reads-hole-receivers.ts new file mode 100644 index 000000000..59f0c4820 --- /dev/null +++ b/tests/corpus/7280-named-reads-hole-receivers.ts @@ -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")); diff --git a/tests/harness/corpus-costs/7280-named-reads-hole-receivers.ts.json b/tests/harness/corpus-costs/7280-named-reads-hole-receivers.ts.json new file mode 100644 index 000000000..db9f23551 --- /dev/null +++ b/tests/harness/corpus-costs/7280-named-reads-hole-receivers.ts.json @@ -0,0 +1 @@ +{ "plain": 4700, "san": 2400 }