"import type" statement to replace typing.TYPE_CHECKING idiom and fix circular references

There is a common circular reference problem in python’s static typing. For example:

example.py

from utils import do_things
class Example:
    pass

def main():
    ex = Example()
    do_things(ex)

utils.py

from example import Example
from foo_bar import i_look_at_annotations

@i_look_at_annotations
def do_things(ex: Example):
    print(f"{ex} is an Example object")

This code won’t run due to the circular import. It’s important to note that the Example class isn’t used directly in the utils module except for in an annotation.

Static typing users avoid this using the if typing.TYPE_CHECKING idiom. This idiom is ugly and confusing, of course. But it also breaks runtime evaluation of annotations. @i_look_at_annotations will never be able to obtain a reference to Example, even if it waits until it is called to evaluate the annotation.

This is one of the last remaining static-typing “warts” of python. PEP 613 fixes forward and recursive references within type aliases, and PEP 649 fixes forward and recursive references in type annotations.

Here is my proposal: an “import type” statement reusing the soft keyword type. For example:

from example import type Example

At runtime, this would be equivalent to:

type Example = __import__('example').Example

That is, it creates a TypeAlias whose __value__ lazily evaluates to example.Example

This would avoid the circular reference problem, replace the ugly “if typing.TYPE_CHECKING” and ensure that runtime annotations can be properly checked.

OPTIONAL PROPOSALS:

  1. I optionally propose that this can be used in conjunction with a regular import statement, for simplicity. i.e. from foo import Foo, type Bar, Baz, type Hoo where Bar and Hoo are TypeAlias but Foo and Baz are not.
  2. I optionally propose that the TypeAlias proxy be extended to be callable, similar to how GenericAlias is callable.
3 Likes

Some related previous discussions:

1 Like

This seems to me more like a code smell. Why does an internal types module of a library need a utility function, if it’s not trying to do too much? I’d introduce a third types module:

_types.py

class Example:
    pass

example.py

from utils import do_things
from _types import Example

def main():
    ex = Example()
    do_things(ex)

utils.py

from _types import Example
from foo_bar import i_look_at_annotations

@i_look_at_annotations
def do_things(ex: Example):
    print(f"{ex} is an Example object")

This would shadow types.py from the stdlib. Better call it something else.

Of course. Good spot - thanks

You could also just not trigger the circular import. :wink: If you import to the module instead of the object, you won’t have the issue, e.g., import utils fixes the problem.

I would also argue it makes the code clearer as you now know where do_things() came from without having to scan the whole file to see if it’s defined there (pragmatically you could skip to the top and look for an import, but shadowing is always a possibility).