From 3ef34770bd7a652169db35bc0d21b8934c43feef Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 01:41:31 -0500 Subject: [PATCH] Read optional elements of bindings that hold undefined as undefined `a?.[i]` on a binding whose type admits undefined, such as a `string[] | undefined` parameter, threw "undefined is not representable in the target union" when the binding held undefined. Element access replaced an identifier receiver whose storage has an undefined arm with a checked narrow to its present arm before the optional chain ran, so the chain saw a receiver that is never nullish and lowered `?.[` as a plain element read. Element access now leaves the receiver to the optional chain when the access carries `?.`; the chain tests the undefined arm and lowers the access again inside its guard. This applies to arrays, tuples and record keys. Property access (`a?.length`, `a?.at(0)`) already lowered through the chain. Corpus 8330 matches Node's output. --- .../compiler/src/frontend/lowering/lowerer.ts | 4 + ...tional-element-undefined-receivers.ts.json | 6 ++ ...30-optional-element-undefined-receivers.ts | 75 +++++++++++++++++++ ...tional-element-undefined-receivers.ts.json | 1 + 4 files changed, 86 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8330-optional-element-undefined-receivers.ts.json create mode 100644 tests/corpus/8330-optional-element-undefined-receivers.ts create mode 100644 tests/harness/corpus-costs/8330-optional-element-undefined-receivers.ts.json diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 15c0ef993..9065e691e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -13145,6 +13145,10 @@ export class Lowerer { ) { return lowerElementAccess(this, expr); } + // `a?.[i]`: the optional chain tests the receiver's undefined arm and + // lowers this access again inside its guard. + if (expr.questionDotToken && !this.chainHandled.has(expr)) + return lowerElementAccess(this, expr); const receiver = this.runtimeOptionalIdentifierValue(expr.expression); const receiverValue: IrExpr | null = receiver && (receiver.present.kind === "array" || receiver.present.kind === "record") diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8330-optional-element-undefined-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8330-optional-element-undefined-receivers.ts.json new file mode 100644 index 000000000..ef78d4391 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8330-optional-element-undefined-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8330-optional-element-undefined-receivers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8330-optional-element-undefined-receivers.ts b/tests/corpus/8330-optional-element-undefined-receivers.ts new file mode 100644 index 000000000..b9433e66c --- /dev/null +++ b/tests/corpus/8330-optional-element-undefined-receivers.ts @@ -0,0 +1,75 @@ +// Optional element access on bindings whose type admits undefined: an +// undefined receiver short-circuits to undefined without evaluating the +// key, and a present receiver reads the element as a plain access does. + +function first(kinds: string[] | undefined, i: number): string { + const kind = kinds?.[i]; + return kind === undefined ? "none" : String(kind); +} +console.log(first(undefined, 0)); +console.log(first(["a", "b"], 1)); +console.log(first(["a"], 5)); + +function orDefault(values: number[] | undefined, i: number): number { + return values?.[i] ?? -1; +} +console.log(orDefault(undefined, 0), orDefault([4, 5], 1), orDefault([4], 3)); + +function isMissing(flags: boolean[] | undefined, i: number): boolean { + return flags?.[i] === undefined; +} +console.log(isMissing(undefined, 0), isMissing([true], 0), isMissing([false], 2)); + +function head(pair: [string, number] | undefined): string { + return pair?.[0] ?? "empty"; +} +console.log(head(undefined), head(["x", 1])); + +function lookup(table: Record | undefined, key: string): number | undefined { + return table?.[key]; +} +console.log(lookup(undefined, "a"), lookup({ a: 1 }, "a"), lookup({ a: 1 }, "b")); + +function width(words: string[] | undefined, i: number): number { + return words?.[i]?.length ?? 0; +} +console.log(width(undefined, 0), width(["abc"], 0), width(["abc"], 1)); + +// The key is not evaluated when the receiver is undefined. +let keys = 0; +function nextKey(): number { + keys++; + return 0; +} +function counted(items: string[] | undefined): string | undefined { + return items?.[nextKey()]; +} +console.log(counted(undefined), keys); +console.log(counted(["k"]), keys); + +// Local bindings, including one reassigned between reads. +function maybe(present: boolean): number[] | undefined { + return present ? [7, 8] : undefined; +} +let current = maybe(false); +console.log(current?.[0]); +current = maybe(true); +console.log(current?.[1]); +current = maybe(false); +console.log(current?.[1]); + +function local(present: boolean): string { + const items: string[] | undefined = present ? ["p", "q"] : undefined; + const second = items?.[1]; + return second ?? "absent"; +} +console.log(local(true), local(false)); + +// A sparse array reads its holes as undefined. +const sparse: number[] = [1]; +sparse[3] = 4; +function at(values: number[] | undefined, i: number): string { + const value = values?.[i]; + return value === undefined ? "hole" : String(value); +} +console.log(at(sparse, 1), at(sparse, 3), at(undefined, 3)); diff --git a/tests/harness/corpus-costs/8330-optional-element-undefined-receivers.ts.json b/tests/harness/corpus-costs/8330-optional-element-undefined-receivers.ts.json new file mode 100644 index 000000000..662eb397f --- /dev/null +++ b/tests/harness/corpus-costs/8330-optional-element-undefined-receivers.ts.json @@ -0,0 +1 @@ +{ "plain": 2108 }