I think we are currently in the confusion period.
It is not clear what the annotations written now were intended to mean. The type checkers don’t handle float consistently. The types will in many cases be confused at runtime as well i.e. there will be actual runtime bugs where the types really are wrong.
It is very easy to make a mistake and accidentally return int
:
def f(nums: list[float]) -> float:
return sum(nums)
Did the author of this truly want to allow f([])
to be an int
?
If I wrote that code then the answer is most definitely no: I wanted the inputs and output to be unambiguously float and not int. This is precisely why a type checker would be useful but current type checkers have allowed these things to be confused which means that the types really will be confused at runtime in many cases.
The parallel thread is trying to standardise the idea that float means float | int
precisely because it is not a consistent behaviour of type checkers right now. I have only really tested mypy and pyright but I would say that pyright does consistently treat float as meaning float | int
in most cases but mypy often does not. A lot of codebases use mypy as their CI type checker and don’t bother with pyright though.
Here is a difference:
def f(x: float):
if not isinstance(x, float):
# mypy thinks this is unreachable
# pyright thinks it is int
reveal_type(x)
x = 1.0
if not isinstance(x, float):
# mypy thinks this is unreachable
# pyright thinks this is int
reveal_type(x)
In the first case you could say that if float means float | int
then pyright is correct and mypy is wrong. However that means that mypy does not implement float means float | int
correctly. In the second case it is unambiguously correct that the code is unreachable so mypy is correct and pyright is wrong.
Thinking that actually unreachable code is reachable is not necessarily a soundness hole but it shows that pyright is not consistently distinguishing between “this is a real float” and “this is annotated as float which means it is float | int
” because if it did then it would know that the branch is unreachable.
Here is an example for Jelle’s unsoundness collection:
def func(x: int) -> str:
y: float | str = x
if not isinstance(y, float):
return y
else:
return str(y)
This checks fine under mypy but not pyright.
These are the overloads involving int and float that I said are fundamental for many scientific and mathematical libraries and that don’t work because the special case causes unsafe overlap:
from typing import overload
class Int: ...
class Float: ...
@overload
def convert(x: int) -> Int: ... # type: ignore
@overload
def convert(x: float) -> Float: ...
def convert(x: int | float) -> Int | Float:
if isinstance(x, int):
return Int()
elif isinstance(x, float):
return Float()
else:
raise TypeError
reveal_type(convert(1)) # Int
reveal_type(convert(1.0)) # Float
Only pyright requires the type: ignore
. According to mypy this type checks fine. In the other thread Jelle said that this was because of a bug in mypy implying that the intention would be to break this in future.
Both mypy and pyright infer the types of Int and Float correctly at the bottom. I have already written overloads like this and just used type: ignore
in various places because in context the unsafe overlap practically never matters and it isn’t possible for a type checker to do much else useful if it can’t infer the most basic types. The other thread is proposing to standardise the idea that this code is wrong but this is not something that type checkers are currently consistent about.
We are in the confusion period right now. Type checkers do not handle float means float | int
consistently. Codebases do not consistently use float
as meaning either literally float or float | int
. Actual floats and ints are mixed up in types at runtime in situations where it was not intended.
It is by checking codebases under --strict-float
that you can resolve the confusion:
- Type checkers can easily implement
--strict-float
consistently.
- Codebases can be fixed so that they really have floats at runtime in the places where they should.
- Annotations can be made accurate to their meaning under the
--strict-float
option while still being consistent in the default mode (writing float | int
works either way if you want that).
It will take time to remove the confusion and you will never know when that process is complete. That is true of everything though in a world of gradual typing where you have things like Any
in the typeshed for even the most basic types like int
and float
.