[ty] Correctly handle calls to functions marked as returning Never / NoReturn (#18333)

## Summary

`ty` does not understand that calls to functions which have been
annotated as having a return type of `Never` / `NoReturn` are terminal.

This PR fixes that, by adding new reachability constraints when call
expressions are seen. If the call expression evaluates to `Never`, the
code following it will be considered to be unreachable. Note that, for
adding these constraints, we only consider call expressions at the
statement level, and that too only inside function scopes. This is
because otherwise, the number of such constraints becomes too high, and
evaluating them later on during type inference results in a major
performance degradation.

Fixes https://github.com/astral-sh/ty/issues/180

## Test Plan

New mdtests.

## Ecosystem changes

This PR removes the following false-positives:
- "Function can implicitly return `None`, which is not assignable to
...".
- "Name `foo` used when possibly not defind" - because the branch in
which it is not defined has a `NoReturn` call, or when `foo` was
imported in a `try`, and the except had a `NoReturn` call.

---------

Co-authored-by: David Peter <mail@david-peter.de>
This commit is contained in:
Abhijeet Prasad Bodas 2025-07-05 00:22:52 +05:30 committed by GitHub
parent a33cff2b12
commit f4bd74ab6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 437 additions and 72 deletions

View file

@ -2,27 +2,53 @@
## Basic functionality
<!-- snapshot-diagnostics -->
`assert_never` makes sure that the type of the argument is `Never`.
`assert_never` makes sure that the type of the argument is `Never`. If it is not, a
`type-assertion-failure` diagnostic is emitted.
### Correct usage
```py
from typing_extensions import assert_never, Never, Any
from ty_extensions import Unknown
def _(never: Never, any_: Any, unknown: Unknown, flag: bool):
def _(never: Never):
assert_never(never) # fine
```
### Diagnostics
<!-- snapshot-diagnostics -->
If it is not, a `type-assertion-failure` diagnostic is emitted.
```py
from typing_extensions import assert_never, Never, Any
from ty_extensions import Unknown
def _():
assert_never(0) # error: [type-assertion-failure]
def _():
assert_never("") # error: [type-assertion-failure]
def _():
assert_never(None) # error: [type-assertion-failure]
def _():
assert_never([]) # error: [type-assertion-failure]
def _():
assert_never({}) # error: [type-assertion-failure]
def _():
assert_never(()) # error: [type-assertion-failure]
def _(flag: bool, never: Never):
assert_never(1 if flag else never) # error: [type-assertion-failure]
def _(any_: Any):
assert_never(any_) # error: [type-assertion-failure]
def _(unknown: Unknown):
assert_never(unknown) # error: [type-assertion-failure]
```

View file

@ -3,7 +3,7 @@ source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assert_never.md - `assert_never` - Basic functionality
mdtest name: assert_never.md - `assert_never` - Basic functionality - Diagnostics
mdtest path: crates/ty_python_semantic/resources/mdtest/directives/assert_never.md
---
@ -15,35 +15,47 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/directives/assert_never.
1 | from typing_extensions import assert_never, Never, Any
2 | from ty_extensions import Unknown
3 |
4 | def _(never: Never, any_: Any, unknown: Unknown, flag: bool):
5 | assert_never(never) # fine
4 | def _():
5 | assert_never(0) # error: [type-assertion-failure]
6 |
7 | assert_never(0) # error: [type-assertion-failure]
7 | def _():
8 | assert_never("") # error: [type-assertion-failure]
9 | assert_never(None) # error: [type-assertion-failure]
10 | assert_never([]) # error: [type-assertion-failure]
11 | assert_never({}) # error: [type-assertion-failure]
12 | assert_never(()) # error: [type-assertion-failure]
13 | assert_never(1 if flag else never) # error: [type-assertion-failure]
14 |
15 | assert_never(any_) # error: [type-assertion-failure]
16 | assert_never(unknown) # error: [type-assertion-failure]
9 |
10 | def _():
11 | assert_never(None) # error: [type-assertion-failure]
12 |
13 | def _():
14 | assert_never([]) # error: [type-assertion-failure]
15 |
16 | def _():
17 | assert_never({}) # error: [type-assertion-failure]
18 |
19 | def _():
20 | assert_never(()) # error: [type-assertion-failure]
21 |
22 | def _(flag: bool, never: Never):
23 | assert_never(1 if flag else never) # error: [type-assertion-failure]
24 |
25 | def _(any_: Any):
26 | assert_never(any_) # error: [type-assertion-failure]
27 |
28 | def _(unknown: Unknown):
29 | assert_never(unknown) # error: [type-assertion-failure]
```
# Diagnostics
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:7:5
--> src/mdtest_snippet.py:5:5
|
5 | assert_never(never) # fine
6 |
7 | assert_never(0) # error: [type-assertion-failure]
4 | def _():
5 | assert_never(0) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^-^
| |
| Inferred type of argument is `Literal[0]`
8 | assert_never("") # error: [type-assertion-failure]
9 | assert_never(None) # error: [type-assertion-failure]
6 |
7 | def _():
|
info: `Never` and `Literal[0]` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -54,13 +66,13 @@ info: rule `type-assertion-failure` is enabled by default
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:8:5
|
7 | assert_never(0) # error: [type-assertion-failure]
7 | def _():
8 | assert_never("") # error: [type-assertion-failure]
| ^^^^^^^^^^^^^--^
| |
| Inferred type of argument is `Literal[""]`
9 | assert_never(None) # error: [type-assertion-failure]
10 | assert_never([]) # error: [type-assertion-failure]
9 |
10 | def _():
|
info: `Never` and `Literal[""]` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -69,16 +81,15 @@ info: rule `type-assertion-failure` is enabled by default
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:9:5
--> src/mdtest_snippet.py:11:5
|
7 | assert_never(0) # error: [type-assertion-failure]
8 | assert_never("") # error: [type-assertion-failure]
9 | assert_never(None) # error: [type-assertion-failure]
10 | def _():
11 | assert_never(None) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^----^
| |
| Inferred type of argument is `None`
10 | assert_never([]) # error: [type-assertion-failure]
11 | assert_never({}) # error: [type-assertion-failure]
12 |
13 | def _():
|
info: `Never` and `None` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -87,16 +98,15 @@ info: rule `type-assertion-failure` is enabled by default
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:10:5
--> src/mdtest_snippet.py:14:5
|
8 | assert_never("") # error: [type-assertion-failure]
9 | assert_never(None) # error: [type-assertion-failure]
10 | assert_never([]) # error: [type-assertion-failure]
13 | def _():
14 | assert_never([]) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^--^
| |
| Inferred type of argument is `list[Unknown]`
11 | assert_never({}) # error: [type-assertion-failure]
12 | assert_never(()) # error: [type-assertion-failure]
15 |
16 | def _():
|
info: `Never` and `list[Unknown]` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -105,16 +115,15 @@ info: rule `type-assertion-failure` is enabled by default
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:11:5
--> src/mdtest_snippet.py:17:5
|
9 | assert_never(None) # error: [type-assertion-failure]
10 | assert_never([]) # error: [type-assertion-failure]
11 | assert_never({}) # error: [type-assertion-failure]
16 | def _():
17 | assert_never({}) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^--^
| |
| Inferred type of argument is `dict[Unknown, Unknown]`
12 | assert_never(()) # error: [type-assertion-failure]
13 | assert_never(1 if flag else never) # error: [type-assertion-failure]
18 |
19 | def _():
|
info: `Never` and `dict[Unknown, Unknown]` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -123,15 +132,15 @@ info: rule `type-assertion-failure` is enabled by default
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:12:5
--> src/mdtest_snippet.py:20:5
|
10 | assert_never([]) # error: [type-assertion-failure]
11 | assert_never({}) # error: [type-assertion-failure]
12 | assert_never(()) # error: [type-assertion-failure]
19 | def _():
20 | assert_never(()) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^--^
| |
| Inferred type of argument is `tuple[()]`
13 | assert_never(1 if flag else never) # error: [type-assertion-failure]
21 |
22 | def _(flag: bool, never: Never):
|
info: `Never` and `tuple[()]` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -140,16 +149,15 @@ info: rule `type-assertion-failure` is enabled by default
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:13:5
--> src/mdtest_snippet.py:23:5
|
11 | assert_never({}) # error: [type-assertion-failure]
12 | assert_never(()) # error: [type-assertion-failure]
13 | assert_never(1 if flag else never) # error: [type-assertion-failure]
22 | def _(flag: bool, never: Never):
23 | assert_never(1 if flag else never) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^--------------------^
| |
| Inferred type of argument is `Literal[1]`
14 |
15 | assert_never(any_) # error: [type-assertion-failure]
24 |
25 | def _(any_: Any):
|
info: `Never` and `Literal[1]` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -158,15 +166,15 @@ info: rule `type-assertion-failure` is enabled by default
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:15:5
--> src/mdtest_snippet.py:26:5
|
13 | assert_never(1 if flag else never) # error: [type-assertion-failure]
14 |
15 | assert_never(any_) # error: [type-assertion-failure]
25 | def _(any_: Any):
26 | assert_never(any_) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^----^
| |
| Inferred type of argument is `Any`
16 | assert_never(unknown) # error: [type-assertion-failure]
27 |
28 | def _(unknown: Unknown):
|
info: `Never` and `Any` are not equivalent types
info: rule `type-assertion-failure` is enabled by default
@ -175,10 +183,10 @@ info: rule `type-assertion-failure` is enabled by default
```
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> src/mdtest_snippet.py:16:5
--> src/mdtest_snippet.py:29:5
|
15 | assert_never(any_) # error: [type-assertion-failure]
16 | assert_never(unknown) # error: [type-assertion-failure]
28 | def _(unknown: Unknown):
29 | assert_never(unknown) # error: [type-assertion-failure]
| ^^^^^^^^^^^^^-------^
| |
| Inferred type of argument is `Unknown`

View file

@ -570,6 +570,229 @@ def f():
reveal_type(x) # revealed: Literal[1]
```
## Calls to functions returning `Never` / `NoReturn`
These calls should be treated as terminal statements.
### No implicit return
If we see a call to a function returning `Never`, we should be able to understand that the function
cannot implicitly return `None`. In the below examples, verify that there are no errors emitted for
invalid return type.
```py
from typing import NoReturn
import sys
def f() -> NoReturn:
sys.exit(1)
```
Let's try cases where the function annotated with `NoReturn` is some sub-expression.
```py
from typing import NoReturn
import sys
# TODO: this is currently not yet supported
# error: [invalid-return-type]
def _() -> NoReturn:
3 + sys.exit(1)
# TODO: this is currently not yet supported
# error: [invalid-return-type]
def _() -> NoReturn:
3 if sys.exit(1) else 4
```
### Type narrowing
If a variable's type is a union, and some types in the union result in a function marked with
`NoReturn` being called, then we should correctly narrow the variable's type.
```py
from typing import NoReturn
import sys
def g(x: int | None):
if x is None:
sys.exit(1)
# TODO: should be just `int`, not `int | None`
# See https://github.com/astral-sh/ty/issues/685
reveal_type(x) # revealed: int | None
```
### Possibly unresolved diagnostics
If the codepath on which a variable is not defined eventually returns `Never`, use of the variable
should not give any diagnostics.
```py
import sys
def _(flag: bool):
if flag:
x = 3
else:
sys.exit()
x # No possibly-unresolved-references diagnostic here.
```
Similarly, there shouldn't be any diagnostics if the `except` block of a `try/except` construct has
a call with `NoReturn`.
```py
import sys
def _():
try:
x = 3
except:
sys.exit()
x # No possibly-unresolved-references diagnostic here.
```
### Bindings in branches
In case of a `NoReturn` call being present in conditionals, the revealed type of the end of the
branch should reflect the path which did not hit any of the `NoReturn` calls. These tests are
similar to the ones for `return` above.
```py
import sys
def call_in_then_branch(cond: bool):
if cond:
x = "terminal"
reveal_type(x) # revealed: Literal["terminal"]
sys.exit()
else:
x = "test"
reveal_type(x) # revealed: Literal["test"]
reveal_type(x) # revealed: Literal["test"]
def call_in_else_branch(cond: bool):
if cond:
x = "test"
reveal_type(x) # revealed: Literal["test"]
else:
x = "terminal"
reveal_type(x) # revealed: Literal["terminal"]
sys.exit()
reveal_type(x) # revealed: Literal["test"]
def call_in_both_branches(cond: bool):
if cond:
x = "terminal1"
reveal_type(x) # revealed: Literal["terminal1"]
sys.exit()
else:
x = "terminal2"
reveal_type(x) # revealed: Literal["terminal2"]
sys.exit()
reveal_type(x) # revealed: Never
def call_in_nested_then_branch(cond1: bool, cond2: bool):
if cond1:
x = "test1"
reveal_type(x) # revealed: Literal["test1"]
else:
if cond2:
x = "terminal"
reveal_type(x) # revealed: Literal["terminal"]
sys.exit()
else:
x = "test2"
reveal_type(x) # revealed: Literal["test2"]
reveal_type(x) # revealed: Literal["test2"]
reveal_type(x) # revealed: Literal["test1", "test2"]
def call_in_nested_else_branch(cond1: bool, cond2: bool):
if cond1:
x = "test1"
reveal_type(x) # revealed: Literal["test1"]
else:
if cond2:
x = "test2"
reveal_type(x) # revealed: Literal["test2"]
else:
x = "terminal"
reveal_type(x) # revealed: Literal["terminal"]
sys.exit()
reveal_type(x) # revealed: Literal["test2"]
reveal_type(x) # revealed: Literal["test1", "test2"]
def call_in_both_nested_branches(cond1: bool, cond2: bool):
if cond1:
x = "test"
reveal_type(x) # revealed: Literal["test"]
else:
x = "terminal0"
if cond2:
x = "terminal1"
reveal_type(x) # revealed: Literal["terminal1"]
sys.exit()
else:
x = "terminal2"
reveal_type(x) # revealed: Literal["terminal2"]
sys.exit()
reveal_type(x) # revealed: Literal["test"]
```
### Overloads
If only some overloads of a function are marked with `NoReturn`, we should run the overload
evaluation algorithm when evaluating the constraints.
```py
from typing import NoReturn, overload
@overload
def f(x: int) -> NoReturn: ...
@overload
def f(x: str) -> int: ...
def f(x): ...
# No errors
def _() -> NoReturn:
f(3)
# This should be an error because of implicitly returning `None`
# error: [invalid-return-type]
def _() -> NoReturn:
f("")
```
### Other callables
If other types of callables are annotated with `NoReturn`, we should still be ablt to infer correct
reachability.
```py
import sys
from typing import NoReturn
class C:
def __call__(self) -> NoReturn:
sys.exit()
def die(self) -> NoReturn:
sys.exit()
# No "implicitly returns `None`" diagnostic
def _() -> NoReturn:
C()()
# No "implicitly returns `None`" diagnostic
def _() -> NoReturn:
C().die()
```
## Nested functions
Free references inside of a function body refer to variables defined in the containing scope.