diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts b/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts index f0724df504..653cc3abc4 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts @@ -143,3 +143,34 @@ test("a call that never returns exits like a throw", () => { // Without the checker's answer a call is an ordinary statement. expect(proofs(body)).toEqual([false, false, false]); }); + +test("a call in an optional chain does not exit, whatever its type", () => { + // Every call named `fail` has type never; `?.` skips the call when its + // receiver or callee is missing, so the statement completes. + const neverReturns = (call: ts.CallExpression): boolean => { + const callee = call.expression; + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ""; + return name === "fail"; + }; + const body = `function f(x: string, maybe: { fail(m: string): never }) { + if (x === undefined) maybe?.fail("missing"); + use(x); + } + function g(x: string) { + if (x === undefined) { this.helper?.fail("missing"); } + use(x); + } + function h(x: string, fail: (m: string) => never) { + if (x === undefined) fail?.("missing"); + use(x); + } + function i(x: string, maybe: { fail(m: string): never }) { + if (x === undefined) maybe!.fail("missing"); + use(x); + }`; + expect(proofs(body, neverReturns)).toEqual([false, false, false, true]); +}); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts b/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts index 0b8a33a5f5..50b175c716 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts @@ -292,6 +292,24 @@ function statementProvesPresence(statement: ts.Statement, bindings: PresenceBind return false; } +/** Whether `call`, or a call or access its callee is made of, uses `?.`: a + * missing receiver or callee then ends the chain without making the call, + * whatever type the checker gives it. */ +function inOptionalChain(call: ts.CallExpression): boolean { + let e: ts.Expression = call; + for (;;) { + if ( + ts.isCallExpression(e) || + ts.isPropertyAccessExpression(e) || + ts.isElementAccessExpression(e) + ) { + if (e.questionDotToken !== undefined) return true; + e = e.expression; + } else if (ts.isNonNullExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression; + else return false; + } +} + /** The statement never completes normally: its last reachable statement * returns, throws, breaks, continues, or calls a function that never * returns. */ @@ -306,7 +324,7 @@ function exitsAbruptly(statement: ts.Statement, bindings: PresenceBindings): boo if (ts.isExpressionStatement(statement)) { let e = statement.expression; while (ts.isParenthesizedExpression(e)) e = e.expression; - return ts.isCallExpression(e) && bindings.neverReturns?.(e) === true; + return ts.isCallExpression(e) && !inOptionalChain(e) && bindings.neverReturns?.(e) === true; } if (ts.isBlock(statement)) { // A labeled statement inside could be the target of an inner break, diff --git a/tests/corpus/never-returning-presence-guard.ts b/tests/corpus/never-returning-presence-guard.ts index 21eee14b76..e1509758ab 100644 --- a/tests/corpus/never-returning-presence-guard.ts +++ b/tests/corpus/never-returning-presence-guard.ts @@ -44,3 +44,19 @@ try { } catch (error) { if (error instanceof Error) console.log(error.message); } + +// Behind `?.`, a missing reporter skips the call, so the read after it can +// still be undefined. +class Reporter { + fail(what: string): never { + throw new Error(what); + } +} +const reporters: Reporter[] = []; +function maybeWord(index: number): string { + const known = words[index]; + if (known === undefined) reporters[index]?.fail(`no word at ${index}`); + return known; +} +const missing = maybeWord(7); +console.log(maybeWord(1), missing === undefined, typeof missing);