I’m concerned that using a tool like MyPy Primer to measure how big a change this is might be misleading.
The primer is a good tool for detecting the impact of a change that makes typing rules stricter, because it asks how much code that type checks today doesn’t type check.
But PEP 724 actually makes typing rules less strict, which means that code that shouldn’t type check today starts type checking. So the problems it causes will show up later, if people are using TypeGuards
that are unsound under the current semantics. In most cases MyPy Primer wouldn’t catch the problem.
Let me explain this in terms of a little example
Consider this code:
def is_even_int(x: int) -> TypeGuard[int]
def boom_if_not_int(x: int) -> None:
# something bad, for argument's sake maybe corrupt my database
...
def end_user_function(x: int | str):
if is_even_int(x):
boom_if_not_int(x: int) # this is safe today
if is_even_int(x):
# I can't do this, or the type checker complains.
# As a result, the type checker is helping me not break my database!
# boom_if_not_int(x: int)
print(x)
Why will mypy primer not complain on this?
This code type checks just fine today:
- the first block refines
x
to an int
and everything is as intended
- the type checker verifies that
int | str
is a subtype of object
in the second branch. Yay!
After PEP 724, it still type checks just fine:
- the first block is exactly the same
- in the second block, the type checker verifies that
int
is a subtype of object
Importantly, this is true for every example. PEP 724 will introduce more refinement, meaning the type of x
will be “smaller” (a subtype of more things), and therefore any code that typechecks before will type check after.
So any changes in MyPy Primer pretty much have to be unusual edge cases, the only case I can think of off the top of my head is when the guarded type isn’t a subtype of the preexisting type
- for example I could have guarded on
int | bytes
, which would cause a “jump” if the type checker doesn’t try to intersect the guarded type with the preexisting type since int | bytes
isn’t a subtype of int | str
- using this kind of type guard is legal, but I would guess not super common
Does that mean my example is safe under the change?
No, shipping PEP 724 could be very dangerous to this code.
The type guard is semantically incorrect under 724. But the type checker has no way of knowing this, so it will go ahead and refine the type of x
to str
.
This type checks just fine. So far so good, I don’t introduce churn by bumping type checker versions.
But what happens if I uncomment the boom_if_not_int(x)
in the else
branch? It still type checks just fine, but I’m going to corrupt my database!
Since the whole reason I wanted typing was to avoid this kind of bug, I’m going to be an unhappy user.
Conclusion
Recapping my intro: PEP 724 is making type checkers less strict, not more strict (it’s confusing because it says “stricter type guards”, but that’s because it’s making the guards themselves stricter, which are contravarient in the guarded type, so it’s making the type checks less strict, not more strict).
But the MyPy Primer is only going to do a good job at measuring issues caused by changes that make type checkers stricter. It will tend to show very few problems when we make type checkers less strict.
The problem is not helped by pinning type checker versions, because you aren’t going to notice the problem (that your type guards are now unsound) when you bump versions, you’ll notice it later when you ship a bug that the type checker should have caught.
To be clear, I’m not saying we shouldn’t accept this PEP (I don’t have a firm opinion at this point), I just think that MyPy Primer results aren’t going to give us a clear picture.
There’s at least a case to be made that in this scenario we should always make a new construct, like TypePredicate[X]
, and then gradually deprecate TypeGuard[X]
.