Short-circuit optional chains on any values only on the guarded step

`a?.b.c` on an `any` value answered undefined when `a` was present and
`a.b` undefined: every later step of the chain read optionally. Node
short-circuits only on the guarded receiver and throws reading `.c`.

An `any` receiver now makes the tail an optional chain like a dyn one,
and inside the chain's guarded body the consumed `?.` no longer marks
the later dynamic reads optional. (Kept from #1561, whose checked-cast
fallback is reverted.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-20 17:36:52 -07:00
co-authored by Claude Opus 5.5
parent c76892dc74
commit 22b9db26b5
5 changed files with 48 additions and 12 deletions
@@ -235,7 +235,7 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
const receiver = tryLowerExpression(lowerer, expr.expression);
if (receiver?.type.kind === "dyn") {
const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN);
const optional = hasOptionalChainGuard(expr.expression);
const optional = hasOptionalChainGuard(expr.expression, lowerer);
return lowerer.maybeNarrow(
{
kind: "dynKeyGet",
@@ -378,7 +378,7 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
lowerer.lowerExpr(expr.argumentExpression),
expr.argumentExpression,
);
const optional = hasOptionalChainGuard(expr.expression);
const optional = hasOptionalChainGuard(expr.expression, lowerer);
return lowerer.maybeNarrow(
{
kind: "dynKeyGet",
@@ -488,7 +488,7 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
? lowerer.coerceToExpected(rawKey, DYN)
: lowerRecordPropertyKey(lowerer, rawKey, expr.argumentExpression);
if (key.type.kind === "string" || key.type.kind === "dyn") {
const opt = hasOptionalChainGuard(expr.expression);
const opt = hasOptionalChainGuard(expr.expression, lowerer);
return lowerer.maybeNarrow(
{
kind: "dynKeyGet",
@@ -98,7 +98,11 @@ export function isRequireMainFilename(lowerer: Lowerer, expr: ts.Expression): bo
export function isOptionalChainTail(lowerer: Lowerer, expr: ts.Expression): boolean {
const tail = chainTailDot(lowerer, expr);
if (!tail) return false;
const recvT = lowerer.mapTypeOf(lowerer.typeOf(tail.expression));
const recvTs = lowerer.typeOf(tail.expression);
// An `any` receiver short-circuits the whole tail too: `a?.b.c` reads
// `.c` normally once `a` is present.
if (recvTs.flags & ts.TypeFlags.Any) return true;
const recvT = lowerer.mapTypeOf(recvTs);
if (recvT?.kind === "dyn") return true;
if (recvT?.kind === "union") {
const def = lowerer.unions.get(recvT.unionId);
@@ -119,7 +123,7 @@ export function isOptionalChainTail(lowerer: Lowerer, expr: ts.Expression): bool
* throwing. Walks the receiver spine only; the argument of the access
* itself is not part of the guard. Parentheses end the chain
* (`(a?.b).c` reads `.c` from undefined); `!` does not. */
export function hasOptionalChainGuard(expr: ts.Expression): boolean {
export function hasOptionalChainGuard(expr: ts.Expression, lowerer?: Lowerer): boolean {
let cur: ts.Expression = expr;
for (;;) {
if (ts.isNonNullExpression(cur)) {
@@ -131,7 +135,11 @@ export function hasOptionalChainGuard(expr: ts.Expression): boolean {
ts.isElementAccessExpression(cur) ||
ts.isCallExpression(cur)
) {
if (cur.questionDotToken) return true;
// Inside an optional chain's guarded body (dynamic reads pass the
// lowerer), the enclosing optChain already short-circuits on the
// consumed token, so the steps above it read normally: `a?.b.c`
// throws when `a.b` is undefined.
if (cur.questionDotToken) return lowerer === undefined || !lowerer.chainHandled.has(cur);
cur = cur.expression;
continue;
}
@@ -874,7 +874,7 @@ export function lowerPropertyAccessExpr(
type: STRING,
loc: locOf(expr.name),
};
const opt = hasOptionalChainGuard(expr.expression);
const opt = hasOptionalChainGuard(expr.expression, lowerer);
return lowerer.maybeNarrow(
{
kind: "dynKeyGet",
@@ -983,7 +983,7 @@ export function lowerPropertyAccessExpr(
type: STRING,
loc: locOf(expr.name),
};
const opt = hasOptionalChainGuard(expr.expression);
const opt = hasOptionalChainGuard(expr.expression, lowerer);
return lowerer.maybeNarrow(
{
kind: "dynKeyGet",
@@ -1115,7 +1115,7 @@ export function lowerPropertyAccessExpr(
type: STRING,
loc: locOf(expr.name),
};
const opt = hasOptionalChainGuard(expr.expression);
const opt = hasOptionalChainGuard(expr.expression, lowerer);
const read: IrExpr = {
kind: "dynKeyGet",
key,
@@ -1210,7 +1210,7 @@ export function lowerPropertyAccessExpr(
type: STRING,
loc: locOf(expr.name),
};
const opt = hasOptionalChainGuard(expr.expression);
const opt = hasOptionalChainGuard(expr.expression, lowerer);
return lowerer.maybeNarrow(
{
kind: "dynKeyGet",
@@ -2829,7 +2829,7 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi
kind: "dynKeyGet",
value,
key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) },
...(expr.questionDotToken || hasOptionalChainGuard(expr.expression)
...(expr.questionDotToken || hasOptionalChainGuard(expr.expression, lowerer)
? { optional: true as const }
: {}),
type: DYN,
@@ -3185,7 +3185,7 @@ export function lowerUnionProperty(
kind: "dynKeyGet",
value: lowerer.coerceToExpected(value, DYN),
key,
...(hasOptionalChainGuard(expr.expression) ? { optional: true as const } : {}),
...(hasOptionalChainGuard(expr.expression, lowerer) ? { optional: true as const } : {}),
type: DYN,
loc: locOf(expr),
};
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/optional-chain-any-tail.ts"
],
"diags": []
}
+22
View File
@@ -0,0 +1,22 @@
// An optional chain on an `any` value short-circuits only on the guarded
// step: `a?.b.c` is undefined when `a` is nullish and throws when `a` is
// present but `a.b` is undefined, as in Node.
function read(label: string, f: () => unknown): void {
try {
console.log(label, f());
} catch (e) {
console.log(label, "threw", (e as Error).message);
}
}
const present: any = JSON.parse('{"b":{"c":{"d":1}},"k":{"method":null}}');
const empty: any = JSON.parse('{"b":{}}');
const none: any = null;
read("present", () => present?.b.c.d);
read("missing tail", () => empty?.b.c.d);
read("nullish root", () => none?.b.c.d);
read("second guard", () => empty?.b.c?.d);
read("element", () => present?.["k"].method);
read("call on nullish", () => none?.["k"].method?.());
read("nested guard", () => empty.x?.b.c);