It’s important to first clarify the distinction between the declared type of a symbol and the locally-narrowed type of a symbol. The declared type of a symbol defines the upper bound of the types that are allowed to be assigned to it. In addition to a symbol’s declared type, a type checker tracks its locally-narrowed type. This is the type that is revealed when you use reveal_type
. It’s also the type that is used when the symbol appears within an expression, and the type checker is evaluating the type of that expression.
x: Literal[1, 2, 3] # Declared type is `Literal[1, 2, 3]`
x = 1
reveal_type(x) # Locally-narrowed type is `Literal[1]`
x = 2
reveal_type(x) # Locally-narrowed type is `Literal[2]`
Pyright is more aggressive than mypy about retaining literal types when narrowing. There are many advantages to this, including the ability to perform literal math, which allows for more aggressive narrowing and various forms of meta-programming.
x: int # Declared type is `int`
x = 1
reveal_type(x) # Mypy: int, pyright: Literal[1]
x = 2 * 4 + 2 ** 2
reveal_type(x) # Mypy: int, pyright: Literal[12]
I understand why one might intuit that the original declared type of a symbol should influence the inferred type of an expression in which the symbol appears, but this isn’t how type checkers work. The declared type is largely irrelevant when evaluating expressions. We need to look at the locally-narrowed types to explain the differences between mypy and pyright.
Consider the following example.
from typing import Final, Literal, cast
# Assign `1` to a bunch of variables
a: int
a = 1
b = 1
c: Final = 1
d: Literal[1, 2, 3]
d = 1
reveal_type(1) # Pyright: Literal[1], Mypy: Literal[1]?
reveal_type([1]) # list[int]
reveal_type(cast(Literal[1], 1)) # Pyright: Literal[1], Mypy: Literal[1]
reveal_type([cast(Literal[1], 1)]) # Pyright: list[int], Mypy: list[Literal[1]]
reveal_type(a) # Pyright: Literal[1], Mypy: int
reveal_type([a]) # list[int]
reveal_type(b) # Pyright: Literal[1], Mypy: int
reveal_type([b]) # list[int]
reveal_type(c) # Pyright: Literal[1], Mypy: Literal[1]?
reveal_type([c]) # list[int]
reveal_type(d) # Pyright: Literal[1], Mypy: Literal[1]
reveal_type([d]) # Pyright: list[int], Mypy: list[Literal[1]]
reveal_type(d * 1) # Pyright: Literal[1], Mypy: int
reveal_type([d * 1]) # list[int]
Note that mypy’s revealed type for the expression 1
is Literal[1]?
. Notably, there is a question mark displayed. Mypy internally treats some Literal
types differently than others. There is no such distinction in the typing spec. I personally find this inconsistent handling of literals undesirable and confusing, but I understand why mypy authors chose this approach. Pyright takes a different approach.
The behavioral differences you’re seeing here between pyright and mypy can be summarized as:
- Pyright is more aggressive at narrowing to literal types (and generally retaining literal types) than mypy.
- Mypy distinguishes internally between two different types of literals whereas pyright treats all literals the same.
- In list, set and dictionary expressions (including comprehensions), pyright does not retain literals by default. Mypy retains literals conditionally based on internal flags that differentiate between different variants of literals.
Contrary to what @mikeshardmind asserted above, both type checkers are correct from a type theory perspective. Both approaches have different tradeoffs. In my experience, pyright’s rules lead to better and more consistent behavior and fewer false positives overall, but there are always tradeoffs.
Inference behaviors for type checkers are intentionally not dictated by the typing spec. Regardless of the inference behavior chosen by a type checker, there will always be situations where there’s a need to override the default inference behavior. With the right set of rules and behaviors, this can be kept to a minimum.
Both mypy and pyright have established their own sets of narrowing rules and inference behaviors, and each set works together cohesively. Changing one rule or behavior in that set typically affects the others. It’s therefore unlikely that either mypy or pyright would change these behaviors at this point. Such a change would create significant churn for users.
@Dr-Irv, if you prefer mypy’s behaviors to pyright’s, then you should use mypy for your projects. The benefit of having multiple type checkers in the Python ecosystem is that you can choose the one that best suits your needs.