microsoft / microsoft/pyright

Inconsistent choice of `Union` variant when multiple choices are valid

Open
#10,719 0 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.