Inconsistent choice of `Union` variant when multiple choices are valid
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 15.6k
- Forks
- 1.8k
- Avg merge
- 12h 13m
- Merged PRs (30d)
- 52
Description
In the following code, `extract` takes a function that returns `Awaitable[T] | T`. When I give it a function that returns `Awaitable[int]` pyright in theory can **choose** `T` to be either `int` or `Awaitable[int]`. Pyright seems to always prefer `int` except for the `f3` case where `int` is wrapped in two generic classes (`A` and `B`) and `async` syntax is used. This discrepancy is unintuitive, I couldn't find any information on how pyright makes its choice in such ambiguous cases.
```python
from collections.abc import Awaitable, Callable
from typing import reveal_type
class A[T]: pass
class B[T]: pass
async def f1() -> int: ...
async def f2() -> A[int]: ...
async def f3() -> A[B[int]]: ...
def g1() -> Awaitable[int]: ...
def g2() -> Awaitable[A[int]]: ...
def g3() -> Awaitable[A[B[int]]]: ...
def extract[T](x: Callable[[], Awaitable[T] | T]) -> T: ...
reveal_type(extract(f1)) # okay: int
reveal_type(extract(f2)) # okay: A[int]
reveal_type(extract(f3)) # weird: Awaitable[A[B[int]]]
reveal_type(extract(g1)) # okay: int
reveal_type(extract(g2)) # okay: A[int]
reveal_type(extract(g3)) # okay: A[B[int]]
```
We can explicitly tell pyright the desired order (first try `Awaitable[T]` and then `T`) by using overloads:
```python
from collections.abc import Awaitable, Callable
from typing import overload, reveal_type
class A[T]: pass
class B[T]: pass
async def f1() -> int: ...
async def f2() -> A[int]: ...
async def f3() -> A[B[int]]: ...
def g1() -> Awaitable[int]: ...
def g2() -> Awaitable[A[int]]: ...
def g3() -> Awaitable[A[B[int]]]: ...
@overload
def extract[T](x: Callable[[], Awaitable[T]]) -> T: ...
@overload
def extract[T](x: Callable[[], T]) -> T: ...
def extract[T](x: Callable[[], Awaitable[T] | T]) -> T: ...
reveal_type(extract(f1)) # int
reveal_type(extract(f2)) # A[int]
reveal_type(extract(f3)) # A[B[int]]
reveal_type(extract(g1)) # int
reveal_type(extract(g2)) # A[int]
reveal_type(extract(g3)) # A[B[int]]
```
But this approach can lead to a lot of overloads everywhere a `Union` with overlapping variants is used. I would prefer pyright to resolve constraints in the same order the `Union` variants are written in the code which would effectively give the same result as overloads.
For reference, mypy doesn't make a choice in ambiguous cases at all. It simply sets `T` to `Never` and gives a type error.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the supplied extract/f1-f3 and g1-g3 reproduction and run it through pyright to confirm the revealed types. Then trace the generic constraint-solving behavior for overlapping Union variants and compare it with the stated overload behavior. Done means the chosen T follows a consistent, documented rule, such as Union order, with regression coverage for these cases.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- devtools
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100