Showing annotated `float` as `int | float` might not be right
Summary
-
Y041inflake8marksfloat | intas redundant -
PEP484 suggests
floatcontainsint -
mypy,pyrightandpyreflyshow annotatedfloatasfloat - However,
tyshows annotatedfloatasint | float
from typing import reveal_type
def foo() -> float:
return 1
reveal_type(foo()) # ty shows int | float
Version
5ea30c4c5
Hey, thanks for the report. Does this FAQ answer your question? https://docs.astral.sh/ty/reference/typing-faq/#why-does-ty-show-int-float-when-i-annotate-something-as-float
Hi, thank you for showing me the FAQ. What I wanted to say is that ty goes against a PEP here, which can be surprising to users.
I don't think ty "goes against a PEP" here. Interpreting an annotation of float as meaning float | int is one way to implement the (not very well specified) comment in PEP 484 about an int "being acceptable" where float is annotated. It is also consistent with Y041 -- an annotation of float | int is redundant when float already means float | int.
float and int are different (and not compatible) types at runtime, so the choice in PEP 484 to pretend they are compatible has left type-checkers with not a great set of choices here. The implementation in ty is not so different from that in pyright, except we choose to show the type to users in the same way it is understood internally.
I'm going to reopen this issue to track whether we do need to do something differently to reduce user confusion here, because we get a lot of reports around this.
Options could include:
- Choosing a different (and unsound) interpretation of the int/float special case.
- Displaying the
int | floatunion type differently.
Thank you for opening the Issue again. Based on my understanding in pandas-dev/pandas-stubs#1574, I think printing as JustFloat | JustInt makes a lot of sense.
I would argue that the special-casing in the typing spec only applies to argument type annotations. This seems to differ from ty's interpretation, which also treats explicit return type float annotations as float | int.
From Python typing spec, emphasis added:
Python’s numeric types complex, float and int are not subtypes of each other, but to support common use cases, the type system contains a straightforward shortcut: when an argument is annotated as having type float, an argument of type int is acceptable; similar, for an argument annotated as having type complex, arguments of type float or int are acceptable.
If only arguments have special-casing, that would imply that the following are typed incorrectly and should each emit a diagnostic:
def foo() -> float:
return 1
def bar(a: float) -> float: # equivalently: (float | int) -> JustFloat
return a
def baz(a: float, b: float) -> float: # equivalently: (float | int, float | int) -> JustFloat
return a + b
since bar(1) and baz(1, 2) both return an int, which is incompatible with float. Some potential fixes that users could do, depending on their intent:
# Modify annotations to clarify expected types
def foo() -> int: ...
def bar(a: float) -> float | int: ...
# -or-
def bar(a: JustFloat) -> float: ...
def baz(a: float, b: float) -> float | int: ...
# -or-
def baz(a: JustFloat, b: JustFloat) -> float: ...
# ---
# Modify impl. to match annotations
def foo() -> float:
return float(1)
def bar(a: float) -> float:
return float(a)
def baz(a: float, b: float) -> float:
return float(a + b)
It is true that the spec only mentions arguments, but no type checker has ever interpreted the special case in that way (all type checkers are OK with your first foo function). I would attribute the wording in the spec to careless word choice in PEP 484 rather than a real intention that the special case should apply only to arguments, given that the authors of PEP 484 were also among the original authors of mypy.
We could of course decide to follow the literal wording in the spec, but since this would be a change from the behavior of all current type checkers, we should not do it just "because the spec says so," but only if we actually believe it's a better approach. I definitely don't think it is. IMO it is a non-starter to have your bar function fail to type-check.
There is general agreement already that the current wording in the spec is under-specified and does not clearly describe a desirable behavior that type checkers are willing to implement. As a result, every type checker currently implements a slightly different (and differently confusing) version of the special case. There isn't, however, consensus yet on how best to clarify it. See https://github.com/python/typing/issues/1746 for extensive prior discussion.
Thank you for the link to the prior discussion, it seems there has been quite an extensive debate! It definitely does sound like a clarification to the spec is needed. The reason I commented earlier was because of the following scenario, which I assumed to be an oversight rather than intentional design:
a = 0.1
b = 0.2
reveal_type(a) # float
reveal_type(b) # float
c = a + b
reveal_type(c) # int | float
I had expected the result of adding two JustFloats would be another JustFloat, and figured the float.__add__(self, value: float, /) -> float declaration from typeshed was causing ty to incorrectly determine the return type was int | float. I opted to add on to this issue since the root causes overlapped, but I'm happy to raise a separate issue if one doesn't already exist.
Makes sense! Feel free to open another issue for that, though I'm not sure there is an obvious short-term resolution in ty, pending a clarification in the spec. If we can agree on the "float annotation means int | float" interpretation of the special case, which ty uses, then it would make sense to introduce a JustFloat annotation, and it would make sense for typeshed to use that type in the return type of float.__add__. But it will be difficult to make this change in typeshed before we have agreement on how type-checkers should handle float annotations.
We could consider patching typeshed or special-casing some methods in ty as a short-term fix.