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.
This commit is contained in:
Chris Tate
2026-10-25 01:41:31 -05:00
parent ec766a5269
commit 3ef34770bd
4 changed files with 86 additions and 0 deletions
@@ -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")
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8330-optional-element-undefined-receivers.ts"
],
"diags": []
}
@@ -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<string, number> | 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));
@@ -0,0 +1 @@
{ "plain": 2108 }