To add to @Daverball’s good points, adding type hints to large existing codebases is a huge task and avoiding TYPE_CHECKING and making all annotations evaluate at runtime is generally impossible. If it really is necessary to get every annotation (e.g. autodoc) then it will never be possible at runtime and a different approach is needed.
Arguing against the use of TYPE_CHECKING in general or asking for every annotation to evaluate isn’t helpful if it is already known to be an impossible request. If there are use cases that require some annotations to evaluate at runtime then what is helpful is to specify which runtime annotations are actually useful. The set of useful runtime annotations that a library can export will be much smaller than the set of all annotations in the library.
My main concern isn’t so much whether TYPE_CHECKING is used, but the prominence and attitude towards it. There’s several situations where you might need to weigh the disadvantages and decide to use the guard, like import cycles or something that can’t be expressed in the type system. But elevating it to builtin status with the motivation to improve performance might inspire libraries to think that it’s best practice to just guard all typing-related things, even though they could be executed perfectly fine. That’ll leave downstream code with an unnecessary black box. It also weakens all type checker’s abilities to validate your program.
I reckon it’d be good to basically document against using TYPE_CHECKING for this sort of library import time purpose. In scripts you might need to, but there you have control over which checkers or tools to use on the code. Earlier it was mentioned to put the constant in say sys, which seems a good idea. That keeps it available for advanced uses, but doesn’t put it front and centre as an attractive nuisance.
I think the issue is whether the use of if TYPE_CHECKING: should be promoted or not. Does Python encourage the use of if TYPE_CHECKING: blocks or does it discourage them? I guess I’ve regarded it as a kludge to work around limitations at the cost of omitting runtime type information, but I may be wrong and type hints should only be used in static analysis except in specific situations.
If Python wants to encourage the use of if TYPE_CHECKING:, it should become a builtin – though I think the name should be __type_checking__
If Python wants to discourage the use of if TYPE_CHECKING: but speed up its use, it should be moved to a compiled extension module – I suggest converting typing to one (I don’t find its current use of meta-programming to be a compelling argument not to do so)
If it does, those type checkers have bugs. They are supposed to process if typing.TYPE_CHECKING blocks unconditionally, which can only mean they have more information, and therefore their abilities are strengthened.
What I mean is that since that’s different to runtime, you can write code which has type check errors or other problems, which can’t be detected. For instance using a guarded import in non-annotation code, or anything inside an else block. Useful of course, but it’s not a typesafe construct.
I think this is a rather myopic view that assumes it has to be one at the cost of the other, it doesnt.
Type checkers don’t understand module __getattr__ use, which can be used to make an import lazy.
I linked my own use of this before in #153, but the pattern looks like:
# _typings.py
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Any, Concatenate, Literal, Never, Self
else:
def __getattr__(name: str):
if name in {"Any", "Concatenate", "Literal", "Never", "Self"}:
import typing
return getattr(typing, name)
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
__all__ = ("TYPE_CHECKING", "Any", "Concatenate", "Literal", "Never", "Self")
# some other file
from __future__ import annotations
from . import _typings as t
def foo(x: t.Literal[1] | t.Literal[2]): # module getattr not evaluated without introspection
...
There’s even tests in that repo to ensure that the annotations can be resolved at runtime. I’d rather if we are going to include guidance for library authors about using this for import time, that it’s guidance that includes things like this that balance all of those concerns, not that tells them their concerns aren’t valid.
Can you give me a practical example of when this would be a problem? I’ve read some code that uses if TYPE_CHECKING: to some extent, and I’ve never felt it was worse than # type: ignore.
The pattern is quite complicated. Understanding it requires significant background knowledge. Additionally, it needs effort to maintain and is brittle because every symbol must be stated three times (in if/else/__all__).
I therefore feel guidance can only go as far as mention this as a possibility not as a recommendation ( „if you want to support runtime information, you can use this pattern“).
We cannot require all library authors to jump these hoops by default. More widespread adoption would require better builtin language support.
Indeed, it’s more “advanced” usage, and is definitely a good reason to have TYPE_CHECKING. I’m more focusing the objection on newer users to typing, in the same way we wouldn’t recommend a beginner use eval(). Your solution is a good balance, but it wouldn’t be desirable to have every library implement this separately.
Michael’s code above is a good example I think - if anything in __getattr__ had a bug, a type checker wouldn’t notice. It’s not really worse than # type: ignore (other than covering a larger block of code), though a lot of the other examples I can think of would involve introspection/metaprogramming, which isn’t exactly straightforward.
That’s rich, considering it’s “not a goal” of python’s type system to catch all errors[1], and as he said:
Typing doesnt feel good in python. it feels like a chore that has way too many costs, and doesnt even provide the same level of guarantees that type systems in other languages can about program wide type safety.
Right now, the competing things people have pointed out are
circular imports for typing only (Not fixable without significant changes to the language)
Import time for typing only annotation use. (anything more than zero is bad here)
Ability to resolve annotations at runtime. (Not every annotation is even used at runtime)
Ability for type checkers to understand. (they already don’t provide type safety)
Type checker authors pushed back against formalizing the special case of TYPE_CHECKING = False in prior threads.
it seems unlikely type checkers are going to learn about module __getattr__
Lazy imports have been rejected by the steering council before.
Import time isn’t magically getting faster (it’s getting slower for typing)
Lazy annotations don’t help unless the import can be avoided.
So what other option do you have that actually helps people who care about multiple of these things without just telling them they are wrong to do it or that it’s too advanced to document as a pattern?
Yes, a type checker can’t check things that you tell it not to check, test coverage can, and using well established patterns that are actually documented in guidance means it’s less likely that people trying to DIY this without guidance get a worse option that has more problems.
I’m not going to drag up a quote, suffice to say, this has been expressed by typing council members. ↩︎
I just measured the time for import typing with different CPython versions:
3.7 - 22ms
3.8 - 17ms
3.9 - 21ms
3.10 - 22ms
3.11 - 22ms
3.12 - 5ms
3.13 - 12ms
3.14a6 - 22ms
If you only look at the last three versions it looks like an exponential increase but more generally maybe 20ms is a kind of equilibrium between the forces of fastness and slowness.
That 20ms is roughly a 50% overhead compared to hello world so a significant overhead for any short program.
My guess is that someone already optimised the import time for Python 3.12 but that their achievement was undone afterwards. Anyone thinking of optimising this now should consider how to prevent it from regressing again in future.
Unless people are laser-focused on keeping import time low it is inevitable that it increases over time. The number one thing that leads to big increases in the import time of module A is just that a change is made so that it now imports another module B which in turn imports C etc.
Usually when modules/packages are first designed people will think about the DAG structure of the import graph. Over time though if you just pepper random imports around (especially cyclic ones) without thinking carefully about what you are doing then the graph will grow a giant component. The end result is that with high probability you can’t import any one thing without basically importing everything.
In a large codebase, adding imports for type annotations in bulk without the if TYPE_CHECKING guard easily ruins the DAG structure like this both locally or in the worst case globally. This is precisely why people have taken the time and effort to make lint rules and fixers that can track this automatically.
In this particular case, it should really be split to a different file.
in myproject/types.py:
import typing
class Foo(typing.TypedDict): pass
in myproject/__init__.py:
if __type_checking__:
from myproject.types import Foo
[...]
The type can’t be imported from myproject, True, but is still available for consumers who need it in myproject.types. I feel like if the writer of a library is sophisticated enough to use typing, they can be expected to know how to arrange the types properly as well.
if __type_checking__:
from myproject.types import Foo
def f(x: Foo) -> Foo:
return x
[...]
Without being able to handle forward references naturally, this pattern isn’t that useful. And if (as I fear) our intended approach to handle forward references is more expensive than import typing, nothing is gained by the pattern anyway.
But the forward reference handling for annotations is lazy, so if you don’t introspect the annotations at runtime (as many codebases never need to do) you pay nothing for the forward references.
That wasn’t my understanding of the current proposal. PEP 649 replaces a function’s annotations with a new function, including cell variables, that may be evaluated later (or not).
How many individual annotations do you think are needed before it takes longer to compile a source file (and takes more memory) than just importing typing once and resolving the types immediately?
My bet is that some libraries will flip the balance on their own, due to the large number of annotations they use. But in any case, once you import hundreds or thousands of source files, you’ll get tens of thousands of additional functions being allocated to avoid having to do a compile-time getattr(typing, NAME) or looking up names in builtins[1].
And still, it only takes one module to do an unprotected import typing and the benefit is gone anyway, and you’re left with only the overhead. I don’t think we can rationalise making TYPE_CHECKING a builtin based on performance - it has to be about convenience and more tightly integrating the concept of type checking into the Python language (which I’m also against, but I’ll argue against that when someone seriously proposes it as the motivation ).
Which is entirely necessary for forward references, but simply overhead for the majority of cases. ↩︎