diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 7ef4b9f25..bede93072 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -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", diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 5dc512a6c..fbcecdc32 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -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; } diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index d121a454e..36f474aab 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -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), }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-chain-any-tail.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-chain-any-tail.ts.json new file mode 100644 index 000000000..b4cffb975 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-chain-any-tail.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/optional-chain-any-tail.ts" + ], + "diags": [] +} diff --git a/tests/corpus/optional-chain-any-tail.ts b/tests/corpus/optional-chain-any-tail.ts new file mode 100644 index 000000000..89e516161 --- /dev/null +++ b/tests/corpus/optional-chain-any-tail.ts @@ -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);