You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
This is helpful in that it clarifies that there is not a subtype relationship here. It remains unclear (to me, at least) in the following ways:
It implies that this special case applies only to function argument annotations, and not to any other annotation (e.g. an annotation of the type of an attribute of a class). I find this surprising, but perhaps it is intended? I think it is surprising enough that if it's intended, the wording should be even clearer, and explicitly show examples where the special case does not apply outside an argument annotation.
The wording "is acceptable" in the absence of a subtype relationship does not clarify how this special case actually fits into the type system; we can accept an int to a float typed argument, but then how do we type that name within the function body? If what we actually mean here is that float should be interpreted as float | int, then we should say that clearly.
There was some discussion of this on #1663, which is about the different but related question of whether to mention the numeric tower and PEP 3141.
We definitely need to change something here. The current wording implying that the special case only applies to parameter types does not describe the behavior of any current type checker, and I don't think it's a desirable behavior.
We already have a test case for this section, specialtypes_promotions.py:
v1: float = 1
v2: complex = 1.2
v2 = 1
def func1(f: float):
f.numerator # E
if not isinstance(f, float):
f.numerator # OK
All type checkers tested pass this test, except pyre which fails to flag the f.numerator line.
Note the v1: float = 1 line, which at least implies that the int/float special case also applies to direct assignment.
However, different type checkers actually interpret this case differently. Pyre and mypy treat the body of the if not isinstance check as unreachable and therefore show no errors; pyright instead narrows the type of f to int within this check. Mypy/pyre's approach implies that int is a real subtype of float (which of course it isn't at runtime), though in some contexts mypy does know that it isn't (example: https://mypy-play.net/?mypy=latest&python=3.12&gist=2a0a984149707b4a770276dca5dcea5e).
I like the interpretation where the name float in an annotation is interpreted instead as the union float | int. This makes the special case into a purely syntactic rule applied when parsing type annotations, instead of a more fundamental special case in the type system. I believe pyright basically follows this interpretation, though it may not be quite this simple since its error messages show that it treats float and float | int as distinct types.
These are sometimes referred to as "promotion types".
Historically, bytes was also treated as a promotion type with implied subtypes of bytearray and memoryview, although this was never documented in any PEP or spec. Both mypy and pyright implement this behavior for bytes, although PEP 688 has eliminated its need. Pyright offers a way to disable the bytes promotion type using the "disableBytesTypePromotions" configuration option. I plan to disable this feature by default at some point in the future.
About a year ago, I changed pyright's handling of promotion types. I now treat float (when it appears in a type expression) as if it were float | int. Likewise, complex is treated as complex | float | int. I got the idea originally from this mypy issue, although mypy hasn't (yet) implemented it.
When compared to the more simplistic approach of always treating int as a subtype of float, this alternative approach more closely matches the runtime behavior when dealing with isinstance checks
deffunc1(f: float):
ifisinstance(f, float):
reveal_type(f) # pyright and mypy reveal "float"else:
reveal_type(f) # pyright reveals "int", mypy considers this unreachable
The downside to this approach is that pyright needs to internally distinguish between two variants of float and complex. One variant is the promotion type, the other is the "pure" type. There's unfortunately no way to spell the "pure" type, nor is there a way to tell the two apart in error messages.
I agree with all the above. Assuming other typing council members feel similarly, I think that clarifies enough to allow a pull request to the spec.
Side note: if we had intersection and negation type expressions, one could spell the "pure" float type as float & ~int. I wouldn't claim that's a nice way to spell it in an error message, though.
The current wording implying that the special case only applies to parameter types does not describe the behavior of any current type checker, and I don't think it's a desirable behavior.
The special casing of calling float-annotated methods with ints, would then help with backwards compatibility / interfacing with other external libraries in cases such as the following:
The major disadvantage of this literal reading of the spec is that it is plainly unsound; the int | float interpretation is sound. The assertion is that this unsoundness is "just for use at boundaries with poorly typed external libraries", but that seems a bit wishful to me -- if the unsoundness exists, it will inevitably spread to all checked code, unless there is some additional mechanism (not yet described) for limiting its application.
Of course, the major advantage of the literal reading is that it permits the intuitive spelling of "really a float". (Though it reduces the confidence that something typed as float will actually be a float at runtime.)
Side issues include:
a) no type checker has ever implemented this interpretation (that I know of), so the ecosystem impact of adopting it is unclear.
b) Presumably "setting an attribute" is not "passing an argument to a function." So this leads to odd inconsistencies between eg constructing an instance of a type with float attribute (special case applies to calling the constructor) vs setting that attribute on the constructed instance (special case no longer applies?)
Reacted by Jelle Zijlstra, Eric Traut and wyattscarpenter
The most important advantage of the literal reading is that it looks like a good path (the best path that I see) towards having BOTH the soundness AND expressability that we want (eventually getting rid of the special case).
That's why changing the spec to remove this reading makes it more difficult to solve the problem.
This issue has several axes that have surfaced in discussion:
What should float mean in variable annotations?
What should float mean in parameter annotations?
How should we value typechecker implementation behaviors vs the spec?
If we decide to lie about the meaning of float, what should the resulting type be?
@carljm, and I think everyone, wants us to answer the last question with "if float means something different, it means float|int" (and not, say, "let's just pretend int is a subtype of float"), because this is more sound, and because the spec doesn't explicitly contradict this interpretation. But this is orthogonal to the benefit that @beauxq, et al, want, which is that if we continue limit this spec special case to parameter annotations, we can eventually get rid of it entirely, with much less frustration for users who rely on third-party code that is a pain to change.
So, I think they are both right, in a manner of speaking.
Having reviewed this several times over the past several months, I've come to the following conclusions:
The spec clearly specifies a behavior.
No(?) typechecker has ever implemented the spec'd behavior.
All typecheckers implement, instead, either behavior X or Y.
It seems they all started with X and then some switched to Y.
This proposal wants the remaining typecheckers to switch from X to Y, a backwards-incompatible change.
Since we're making a backwards-incompatible change to the spec for greater soundness, I have my own proposal:
1️⃣ Remove the special case from the spec.
This is both more sound and more consistent than doing nothing.
However, I think we can have everybody's cake and eat it too. 2️⃣ Above the proposed new spec language, add "to support backwards compatibility, type checkers may optionally expose a configuration to enable implicit promotion between these types. If that configuration is enabled, the following behavior should occur:"
The ultimate goal of doing this is for everyone to update their libraries that need it from float to float|int; an extremely simple change, since they can just find-and-replace. However, it also supports current typechecker behavior, backwards compatibility, and soundness.
This closely mirrors the story of bytes in pyright (and also mypy, to some extent). I guess bytes wasn't mentioned by the spec, and this just happened organically? In any case, I defer to Eric, et al, those with experience, about the successfulness of that strategy.
Now, if we want to give up all current typechecker behavior compatibility (which we're already sacrificing on, since we all agree mypy's current behavior is bad) we could get another benefit, gradual adoption gradient (not to mention, being more akin to a clarification to the current spec, although so far no proposed change is actually just clarification to the spec): 3️⃣ specify that this implicit promotion only takes place in parameter annotations. Therefore, in your own code you immediately get all the float type errors, which you can easily fix using find-and-replace; but for third-party code, which is presumably harder for you to fix, but which the typechecker does not check the bodies of, they keep saying they take a float and you keep giving them an int, until maybe someday they fix their type signatures and then you can turn the implicit parameter promotion config off.
That's probably the best option.
For the purposes of completeness, I should note that the minimal resolution to this problem is 4️⃣ like Jelle's current (very good) pull request, but explicitly limiting the syntactic sugar special case to parameter annotations, like in the current spec. In this way, soundness is achieved but no other changes are made. This possibility has been unrecognized so far(?) due to its accidental conflation with 0️⃣, do nothing and continue to spec the current spec'd behavior — which no one likes because it is unsound.
typevar musing
5️⃣ The `float|int` construct could also be made a typevar constrained to float and int, instead, but that would probably break various things by necessitating a HKT, which we don't have, in several places.
There's lots more discussion about this on Discourse. Many people want the special case gone, some prefer some behavior like what mypy currently does.
I'd rather not keep a variant of the special case that treats parameters different from other places. No type checker has ever implemented it, and I believe it would make for very confusing behavior. (For example, def f(x: float) -> float: return x would not type check.)
The major disadvantage of this literal reading of the spec is that it is plainly unsound
I completely agree, and think nobody here is of the opinion that the current spec are satisfying.
But then let's be honest and clearly state the problem: the current spec around float/int handling is unsound, complicated, not implemented by typecheckers and introduces more problems than it solves.
Once we agree on this point, the next step would be to openly discuss possible solutions. The two that were mentioned most are:
A: int | float
B: remove the special handling completely
Both are, almost by definition, breaking changes to the spec. Of course, it's a valid argument in favor of A that this behavior is (partially) implemented in some type checkers and seems to work reasonable well. On the other hand, there were quite a few issues brought forward showcasing how A is actually unsound and has logical inconsistencies as well. For solution B no such inconsistencies were mentioned, as far as I can see.
However, other than that, I completely agree with you.
Unnecessarily long elaboration of my position, included only for completeness
I possibly should have been clearer in my October 20th comment that I was spurred by your comment in the Discourse to read more closely the current spec, and thereby come to agree with your general outlook. I don't think the spec is ambiguous at all, contra OP. I think it has a natural reading. "when an argument is annotated" is not ambiguous and the term "acceptable" is not ambiguous (PEP 484 uses this term 10 times and it seems to mean "the typechecker should accept it"). It may not have been what Guido wanted to specify, but it's specified. The thing that causes confusion with regards to this paragraph is that no typecheckers have ever implemented this behavior, and also that this behavior is a bad idea. There are many instances of people claiming that such-and-such is already spec'd behavior in the Discourse thread, but this is often because they did not read the spec carefully.
I don't think it's inconsistent with the spec for typecheckers to make the resulting type float | int for analysis, but this is their prerogative, not an interpretation of an unclear part of the spec; I imagine they are using their implementor's freedom here to help you catch more type errors, much like how they can disallow using certain constructions that they've figured out are unsafe even though those aren't mentioned in the spec. Or how they could treat bool as distinct from int if they wanted, to help you catch more type errors.
I also do not think it's impermissible to change the spec. I think it's a good idea to change it to be more sound and consistent.
I am not sure if mypy implements the currently-spec'd behavior or not. Most of the examples claiming that it does not seem to a bit ambiguous. And if we find some place where it slips up, that might just be a bug in mypy. For instance, (over a year ago) Jelle used code after if not isinstance(f, float) being unreachable to argue that mypy sees int as a "real subtype of float" — which I then assumed was mypy's model, based on that argument. And that would be very far from the current spec, indeed. But that code occurs in a function with a parameter annotated float. So it may just regard float as a subtype of float. Which is correct. On the other hand, to follow the spec it should flag v1: float = 1 as wrong, which it does not.
The property of soundness only guarantees that you don't run into runtime type errors if you don't run into static type errors. (That's one way to put it, at least.) And you're never going to hit a runtime type error if the type checker is making you put isinstance checks everywhere.
Suppose you have a library that exposes a function, that really only accepts floats at runtime (say it calls hex, or is implemented in Cython or whatever). With the proposed changes, the library author cannot express the fact that this method only accepts float. So passing an int like 1 will not trigger a type check error but will throw a runtime error. So, the only way to make this "sound" is to force every method to always accept ints and floats.
That's not what soundness generally means. You might also have a function that only accepts positive floats, but the type system doesn't support a way to express that. The float/int special case means that some types are not expressible in the type system. That's a major downside, but it doesn't make it unsound.
So, the only way to make this "sound" is to force every method to always accept ints and floats.
Yes, that is precisely what the int | float option does, which makes it sound. A function that can really only handle floats must do an isinstance check internally to distinguish int from float.
It's also valid, sound, and consistent under float | int; it's just a no-op (because the returned TypeGuard doesn't distinguish int vs float either), and the float | int annotation on the input is redundant, could just be float.
For a function returning TypeIs[T] to be correct, it must return True if and only if the argument is of type T, and False otherwise. If this condition is not met, the type checker may infer incorrect types.
According to this, the following would be the "correct" type guard with float = float | int:
Moreover, if you view is_float_list as a typeguard for float | int, the second part in the above example
ifis_float_list(val):
return [x.hex() forxinval]
will trigger typing errors, although you just checked that all items in the list are really floats...
The only way to make this work would be to teach the type checker that float in a TypeGuard really just means a float. By the way, this is implicit in the proposal anyway because you want something like this:
defis_float(val):
returnisinstance(x, float)
val= ...
ifisinstance(val, float):
val.hex() # okay because `ininstance(., float)` is treated as a `TypeGuard/TypeIs[really float]`ifis_float(val):
val.hex() # should be okay, because `is_float` is just a wrapper around isinstance
This only works if the type checker infers that is_float is a type checker for "really float"
Your original example used TypeGuard which has different semantics from the ones you quoted for TypeIs; it's fine for a TypeGuard to "over filter", it's not ok for TypeIs. So you're right that when you switch to TypeIs, then you need to make sure the body of the check passes for ints, too.
Your second example doesn't really make sense regardless of how int vs float is handled; type checkers do not "look inside" the bodies of functions to automatically infer that they should be treated as type guards. No matter what conclusion we reach here, type checkers are not going to treat is_float as narrowing the type of its argument at all, unless it's explicitly annotated with TypeGuard or TypeIs.
These additional examples aren't illuminating anything new. The int | float interpretation is sound, it's just imprecise in that it leaves no way to spell "just a float" in a type annotation.
The point that I was trying to make is that with this proposal you force people to add instance checks, as you noted yourself:
A function that can really only handle floats must do an isinstance check internally to distinguish int from float.
On the other hand, you cannot even express these isinstance checks for floats correctly using the type system. So, in the examples above, you cannot annotate is_float/is_float_list so that code passes the type check (with the int | float interpretation, while currently it's not a problem at all using basic float).
I agree the inability to write a type-narrowing function that distinguishes int and float is an issue. I think a generic version like this arguably should work (because float here in the call to is_instance_of is not a type annotation, it's a reference to the runtime float class, therefore I think T should solve to really-a-float), but this does not work in pyright today:
Changing the test here to use builtin isinstance directly instead of custom-defined is_instance_of fixes it. I don't like it either that there's no way to reproduce the behavior of builtin isinstance in a user-defined type guard function.
currently it's not a problem at all using basic float
...depending which type checker you use. Pyright already implements the int | float interpretation today. So any clarification to the spec here means a backwards-incompatible change in behavior for some widely-used type checker.
Btw, what would type[float] actually mean since T in type[T] might be a concrete class or a type variable?
Not quite sure how to interpret this question. Under the int | float scheme, the type expression type[float] is equivalent to type[float | int]. But I think that passing the builtin float class (a value expression, not a type expression) as argument to a parameter typed as type[T] should solve T to really-a-float, not to float | int.
But I think that passing the builtin float class (a value expression, not a type expression) as argument to a parameter typed as type[T] should solve T to really-a-float, not to float | int.
How do you distinguish float as a value expression and as type expression?
In general it's possible to have types in your type system that are not expressible by users that the type checker can infer. Python type checkers e.g. often have limited forms of intersection types, or e.g. ways to represent functools.partial, that users do not have a way to spell for themselves in their own type annotations.
The proposed spec change only affects the interpretation of type annotations. The type guard resulting from isinstance(x, float) is still a thing type checkers could infer and represent internally, but users do not have a way to spell. That is, type checkers could infer a TypeGuard[ActuallyFloatActually] type. Or for the is_instance_of generic case Carl mentions, a type checker could infer a type[ActuallyFloatActually] type from the float value.
If the proposed spec change were implemented, one could even write a PEP that gives users a way to spell this type, e.g. from typing import ActuallyFloatActually. This type would be infectious and a pain to educate users on, so if I were to be in favour I'd want to see a compelling case that users actually run into a lot of bugs with functions that call .hex() or C extension functions that only take float types
Reacted by wyattscarpenter, Tobias Diez, Carl Meyer and Eric Traut
I'd want to see a compelling case that users actually run into a lot of bugs with functions that call .hex() or C extension functions that only take float types
I do see the advantage of the unsound interpretation of the special case (where int and float remain separate types, but int is "wrongly" assignable to float), in that it would allow type checkers to add a strict-floats flag, for users who want to opt in to explicit conversion of ints to floats and close the soundness hole, without changing the interpretation of the universe of float type hints in libraries. With the float | int interpretation, such a flag is not really feasible.
One disadvantage of the unsound interpretation is that it gives libraries no way at all to tell users "no really, you must pass me actual floats" (other than out-of-band documentation). (Of course, this is also true today, but fixable by adding a new type spelling.) It leaves it up to the user to decide if they want to be strict, not up to the library author.
Activity
JelleZijlstra commentedon May 23, 2024
We definitely need to change something here. The current wording implying that the special case only applies to parameter types does not describe the behavior of any current type checker, and I don't think it's a desirable behavior.
We already have a test case for this section,
specialtypes_promotions.py:All type checkers tested pass this test, except pyre which fails to flag the
f.numeratorline.Note the
v1: float = 1line, which at least implies that the int/float special case also applies to direct assignment.However, different type checkers actually interpret this case differently. Pyre and mypy treat the body of the
if not isinstancecheck as unreachable and therefore show no errors; pyright instead narrows the type offtointwithin this check. Mypy/pyre's approach implies that int is a real subtype of float (which of course it isn't at runtime), though in some contexts mypy does know that it isn't (example: https://mypy-play.net/?mypy=latest&python=3.12&gist=2a0a984149707b4a770276dca5dcea5e).I like the interpretation where the name
floatin an annotation is interpreted instead as the unionfloat | int. This makes the special case into a purely syntactic rule applied when parsing type annotations, instead of a more fundamental special case in the type system. I believe pyright basically follows this interpretation, though it may not be quite this simple since its error messages show that it treatsfloatandfloat | intas distinct types.erictraut commentedon May 23, 2024
I agree that this section should be clarified.
These are sometimes referred to as "promotion types".
Historically,
byteswas also treated as a promotion type with implied subtypes ofbytearrayandmemoryview, although this was never documented in any PEP or spec. Both mypy and pyright implement this behavior forbytes, although PEP 688 has eliminated its need. Pyright offers a way to disable thebytespromotion type using the "disableBytesTypePromotions" configuration option. I plan to disable this feature by default at some point in the future.About a year ago, I changed pyright's handling of promotion types. I now treat
float(when it appears in a type expression) as if it werefloat | int. Likewise,complexis treated ascomplex | float | int. I got the idea originally from this mypy issue, although mypy hasn't (yet) implemented it.When compared to the more simplistic approach of always treating
intas a subtype offloat, this alternative approach more closely matches the runtime behavior when dealing withisinstancechecksThe downside to this approach is that pyright needs to internally distinguish between two variants of
floatandcomplex. One variant is the promotion type, the other is the "pure" type. There's unfortunately no way to spell the "pure" type, nor is there a way to tell the two apart in error messages.carljm commentedon May 23, 2024
I agree with all the above. Assuming other typing council members feel similarly, I think that clarifies enough to allow a pull request to the spec.
Side note: if we had intersection and negation type expressions, one could spell the "pure" float type as
float & ~int. I wouldn't claim that's a nice way to spell it in an error message, though.hauntsaninja commentedon May 23, 2024
Yeah, I think KotlinIsland's union of ducklings approach better matches runtime behaviour than what mypy currently does
JelleZijlstra commentedon May 23, 2024
Posted about this in https://discuss.python.org/t/clarifying-the-float-int-complex-special-case/54018.
beauxq commentedon Oct 18, 2025
This comment https://discuss.python.org/t/clarifying-the-float-int-complex-special-case/54018/85
explains well why it is desirable.
It's the most valuable way to ease the friction between codebases using the safer, sounder checking, and codebases using the old problematic way.
The most important way to clarify the special case is to change the conformance tests to match what the specification says.
carljm commentedon Oct 18, 2025
The major disadvantage of this literal reading of the spec is that it is plainly unsound; the
int | floatinterpretation is sound. The assertion is that this unsoundness is "just for use at boundaries with poorly typed external libraries", but that seems a bit wishful to me -- if the unsoundness exists, it will inevitably spread to all checked code, unless there is some additional mechanism (not yet described) for limiting its application.Of course, the major advantage of the literal reading is that it permits the intuitive spelling of "really a float". (Though it reduces the confidence that something typed as float will actually be a float at runtime.)
Side issues include:
a) no type checker has ever implemented this interpretation (that I know of), so the ecosystem impact of adopting it is unclear.
b) Presumably "setting an attribute" is not "passing an argument to a function." So this leads to odd inconsistencies between eg constructing an instance of a type with float attribute (special case applies to calling the constructor) vs setting that attribute on the constructed instance (special case no longer applies?)
beauxq commentedon Oct 18, 2025
The most important advantage of the literal reading is that it looks like a good path (the best path that I see) towards having BOTH the soundness AND expressability that we want (eventually getting rid of the special case).
That's why changing the spec to remove this reading makes it more difficult to solve the problem.
wyattscarpenter commentedon Oct 20, 2025
This issue has several axes that have surfaced in discussion:
What should
floatmean in variable annotations?What should
floatmean in parameter annotations?How should we value typechecker implementation behaviors vs the spec?
If we decide to lie about the meaning of
float, what should the resulting type be?@carljm, and I think everyone, wants us to answer the last question with "if
floatmeans something different, it meansfloat|int" (and not, say, "let's just pretendintis a subtype offloat"), because this is more sound, and because the spec doesn't explicitly contradict this interpretation. But this is orthogonal to the benefit that @beauxq, et al, want, which is that if we continue limit this spec special case to parameter annotations, we can eventually get rid of it entirely, with much less frustration for users who rely on third-party code that is a pain to change.So, I think they are both right, in a manner of speaking.
wyattscarpenter commentedon Oct 20, 2025
Having reviewed this several times over the past several months, I've come to the following conclusions:
Since we're making a backwards-incompatible change to the spec for greater soundness, I have my own proposal:
1️⃣ Remove the special case from the spec.
This is both more sound and more consistent than doing nothing.
However, I think we can have everybody's cake and eat it too. 2️⃣ Above the proposed new spec language, add "to support backwards compatibility, type checkers may optionally expose a configuration to enable implicit promotion between these types. If that configuration is enabled, the following behavior should occur:"
The ultimate goal of doing this is for everyone to update their libraries that need it from
floattofloat|int; an extremely simple change, since they can just find-and-replace. However, it also supports current typechecker behavior, backwards compatibility, and soundness.This closely mirrors the story of
bytesin pyright (and also mypy, to some extent). I guessbyteswasn't mentioned by the spec, and this just happened organically? In any case, I defer to Eric, et al, those with experience, about the successfulness of that strategy.Now, if we want to give up all current typechecker behavior compatibility (which we're already sacrificing on, since we all agree mypy's current behavior is bad) we could get another benefit, gradual adoption gradient (not to mention, being more akin to a clarification to the current spec, although so far no proposed change is actually just clarification to the spec): 3️⃣ specify that this implicit promotion only takes place in parameter annotations. Therefore, in your own code you immediately get all the float type errors, which you can easily fix using find-and-replace; but for third-party code, which is presumably harder for you to fix, but which the typechecker does not check the bodies of, they keep saying they take a float and you keep giving them an int, until maybe someday they fix their type signatures and then you can turn the implicit parameter promotion config off.
That's probably the best option.
For the purposes of completeness, I should note that the minimal resolution to this problem is 4️⃣ like Jelle's current (very good) pull request, but explicitly limiting the syntactic sugar special case to parameter annotations, like in the current spec. In this way, soundness is achieved but no other changes are made. This possibility has been unrecognized so far(?) due to its accidental conflation with 0️⃣, do nothing and continue to spec the current spec'd behavior — which no one likes because it is unsound.
typevar musing
5️⃣ The `float|int` construct could also be made a typevar constrained to float and int, instead, but that would probably break various things by necessitating a HKT, which we don't have, in several places.JelleZijlstra commentedon Oct 20, 2025
There's lots more discussion about this on Discourse. Many people want the special case gone, some prefer some behavior like what mypy currently does.
I'd rather not keep a variant of the special case that treats parameters different from other places. No type checker has ever implemented it, and I believe it would make for very confusing behavior. (For example,
def f(x: float) -> float: return xwould not type check.)tobiasdiez commentedon Oct 25, 2025
I completely agree, and think nobody here is of the opinion that the current spec are satisfying.
But then let's be honest and clearly state the problem: the current spec around float/int handling is unsound, complicated, not implemented by typecheckers and introduces more problems than it solves.
Once we agree on this point, the next step would be to openly discuss possible solutions. The two that were mentioned most are:
int | floatBoth are, almost by definition, breaking changes to the spec. Of course, it's a valid argument in favor of A that this behavior is (partially) implemented in some type checkers and seems to work reasonable well. On the other hand, there were quite a few issues brought forward showcasing how A is actually unsound and has logical inconsistencies as well. For solution B no such inconsistencies were mentioned, as far as I can see.
wyattscarpenter commentedon Oct 26, 2025
@tobiasdiez I think the
float⇒float | int(etc) special case is always sound, just inconsistent (in an informal sense). The property of soundness only guarantees that you don't run into runtime type errors if you don't run into static type errors. (That's one way to put it, at least.) And you're never going to hit a runtime type error if the type checker is making you put isinstance checks everywhere. I don't see any evidence to the contrary in the other two threads (https://discuss.python.org/t/clarifying-the-float-int-complex-special-case/54018 and https://discuss.python.org/t/options-for-a-long-term-fix-of-the-special-case-for-float-int-complex/54289), although I may perhaps have missed something.However, other than that, I completely agree with you.
Unnecessarily long elaboration of my position, included only for completeness
I possibly should have been clearer in my October 20th comment that I was spurred by your comment in the Discourse to read more closely the current spec, and thereby come to agree with your general outlook. I don't think the spec is ambiguous at all, contra OP. I think it has a natural reading. "when an argument is annotated" is not ambiguous and the term "acceptable" is not ambiguous (PEP 484 uses this term 10 times and it seems to mean "the typechecker should accept it"). It may not have been what Guido wanted to specify, but it's specified. The thing that causes confusion with regards to this paragraph is that no typecheckers have ever implemented this behavior, and also that this behavior is a bad idea. There are many instances of people claiming that such-and-such is already spec'd behavior in the Discourse thread, but this is often because they did not read the spec carefully.
I don't think it's inconsistent with the spec for typecheckers to make the resulting type
float | intfor analysis, but this is their prerogative, not an interpretation of an unclear part of the spec; I imagine they are using their implementor's freedom here to help you catch more type errors, much like how they can disallow using certain constructions that they've figured out are unsafe even though those aren't mentioned in the spec. Or how they could treatboolas distinct fromintif they wanted, to help you catch more type errors.I also do not think it's impermissible to change the spec. I think it's a good idea to change it to be more sound and consistent.
I am not sure if mypy implements the currently-spec'd behavior or not. Most of the examples claiming that it does not seem to a bit ambiguous. And if we find some place where it slips up, that might just be a bug in mypy. For instance, (over a year ago) Jelle used code after
if not isinstance(f, float)being unreachable to argue that mypy sees int as a "real subtype of float" — which I then assumed was mypy's model, based on that argument. And that would be very far from the current spec, indeed. But that code occurs in a function with a parameter annotated float. So it may just regard float as a subtype of float. Which is correct. On the other hand, to follow the spec it should flagv1: float = 1as wrong, which it does not.tobiasdiez commentedon Oct 27, 2025
Suppose you have a library that exposes a function, that really only accepts floats at runtime (say it calls hex, or is implemented in Cython or whatever). With the proposed changes, the library author cannot express the fact that this method only accepts
float. So passing an int like1will not trigger a type check error but will throw a runtime error. So, the only way to make this "sound" is to force every method to always accept ints and floats.JelleZijlstra commentedon Oct 27, 2025
That's not what soundness generally means. You might also have a function that only accepts positive floats, but the type system doesn't support a way to express that. The float/int special case means that some types are not expressible in the type system. That's a major downside, but it doesn't make it unsound.
carljm commentedon Oct 27, 2025
Yes, that is precisely what the
int | floatoption does, which makes it sound. A function that can really only handle floats must do anisinstancecheck internally to distinguish int from float.tobiasdiez commentedon Oct 27, 2025
So "soundness" then only refers to the internal logical consistency of the typing system?
But perhaps
TypeGuard[float]is then a better example for inconsistent behavior underfloat = float | int:is currently valid and pretty much impossible to annotate with
float = float | int.carljm commentedon Oct 27, 2025
It's also valid, sound, and consistent under
float | int; it's just a no-op (because the returned TypeGuard doesn't distinguish int vs float either), and thefloat | intannotation on the input is redundant, could just befloat.tobiasdiez commentedon Oct 28, 2025
From https://typing.python.org/en/latest/guides/type_narrowing.html#writing-a-correct-typeis-function
According to this, the following would be the "correct" type guard with
float = float | int:Moreover, if you view
is_float_listas a typeguard forfloat | int, the second part in the above examplewill trigger typing errors, although you just checked that all items in the list are really floats...
The only way to make this work would be to teach the type checker that
floatin aTypeGuardreally just means a float. By the way, this is implicit in the proposal anyway because you want something like this:This only works if the type checker infers that
is_floatis a type checker for "really float"carljm commentedon Oct 28, 2025
Your original example used
TypeGuardwhich has different semantics from the ones you quoted forTypeIs; it's fine for aTypeGuardto "over filter", it's not ok forTypeIs. So you're right that when you switch toTypeIs, then you need to make sure the body of the check passes for ints, too.Your second example doesn't really make sense regardless of how int vs float is handled; type checkers do not "look inside" the bodies of functions to automatically infer that they should be treated as type guards. No matter what conclusion we reach here, type checkers are not going to treat
is_floatas narrowing the type of its argument at all, unless it's explicitly annotated with TypeGuard or TypeIs.These additional examples aren't illuminating anything new. The
int | floatinterpretation is sound, it's just imprecise in that it leaves no way to spell "just a float" in a type annotation.tobiasdiez commentedon Oct 29, 2025
The point that I was trying to make is that with this proposal you force people to add instance checks, as you noted yourself:
On the other hand, you cannot even express these isinstance checks for floats correctly using the type system. So, in the examples above, you cannot annotate
is_float/is_float_listso that code passes the type check (with theint | floatinterpretation, while currently it's not a problem at all using basicfloat).carljm commentedon Oct 29, 2025
I agree the inability to write a type-narrowing function that distinguishes int and float is an issue. I think a generic version like this arguably should work (because
floathere in the call tois_instance_ofis not a type annotation, it's a reference to the runtimefloatclass, therefore I thinkTshould solve to really-a-float), but this does not work in pyright today:Changing the test here to use builtin
isinstancedirectly instead of custom-definedis_instance_offixes it. I don't like it either that there's no way to reproduce the behavior of builtinisinstancein a user-defined type guard function....depending which type checker you use. Pyright already implements the
int | floatinterpretation today. So any clarification to the spec here means a backwards-incompatible change in behavior for some widely-used type checker.tobiasdiez commentedon Oct 29, 2025
Btw, what would
type[float]actually mean sinceTintype[T]might be a concrete class or a type variable?carljm commentedon Oct 29, 2025
Not quite sure how to interpret this question. Under the
int | floatscheme, the type expressiontype[float]is equivalent totype[float | int]. But I think that passing the builtinfloatclass (a value expression, not a type expression) as argument to a parameter typed astype[T]should solveTto really-a-float, not tofloat | int.tobiasdiez commentedon Oct 30, 2025
I was wondering about this example:
How do you distinguish
floatas a value expression and as type expression?hauntsaninja commentedon Oct 30, 2025
In general it's possible to have types in your type system that are not expressible by users that the type checker can infer. Python type checkers e.g. often have limited forms of intersection types, or e.g. ways to represent
functools.partial, that users do not have a way to spell for themselves in their own type annotations.The proposed spec change only affects the interpretation of type annotations. The type guard resulting from
isinstance(x, float)is still a thing type checkers could infer and represent internally, but users do not have a way to spell. That is, type checkers could infer aTypeGuard[ActuallyFloatActually]type. Or for theis_instance_ofgeneric case Carl mentions, a type checker could infer atype[ActuallyFloatActually]type from thefloatvalue.If the proposed spec change were implemented, one could even write a PEP that gives users a way to spell this type, e.g.
from typing import ActuallyFloatActually. This type would be infectious and a pain to educate users on, so if I were to be in favour I'd want to see a compelling case that users actually run into a lot of bugs with functions that call.hex()or C extension functions that only take float typescarljm commentedon Dec 10, 2025
I do think that case has been made a few times now; probably most thoroughly at https://discuss.python.org/t/options-for-a-long-term-fix-of-the-special-case-for-float-int-complex/54289/90 -- not sure if you find it convincing or not. (I don't think I've seen any claims that
.hex()is a real practical problem; the real problem is C libraries.)I do see the advantage of the unsound interpretation of the special case (where
intandfloatremain separate types, butintis "wrongly" assignable tofloat), in that it would allow type checkers to add a strict-floats flag, for users who want to opt in to explicit conversion of ints to floats and close the soundness hole, without changing the interpretation of the universe offloattype hints in libraries. With thefloat | intinterpretation, such a flag is not really feasible.One disadvantage of the unsound interpretation is that it gives libraries no way at all to tell users "no really, you must pass me actual floats" (other than out-of-band documentation). (Of course, this is also true today, but fixable by adding a new type spelling.) It leaves it up to the user to decide if they want to be strict, not up to the library author.