Skip to content

[Feature Request] beartype.claw import hooks transform runtime-hostile if TYPE_CHECKING: antipattern to runtime-friendly PEP 810 lazy imports = 🥹  #477

Open

Description

@deepyaman

Hello! I decided to try beartype recently (read: today). I'm working on a large codebase with a hand-rolled runtime type checking system, but everything is also type-annotated, so why not use beartype and halve the work?

Unfortunately, I ran into an issue quite quickly (may just be user error!), because of forward references with deferred imports. Here's a quick reproduction:

Module definition

bears/__init__.py

from bears.core import Bear

bears/core.py

from typing import TYPE_CHECKING, Optional

import polars as pl
from beartype import beartype

if TYPE_CHECKING:
    from bears.persistent import PolarsBear


@beartype
class Bear:
    def __init__(self, name: str, bear_type: Optional[str] = None) -> None:
        from bears.ephemeral import SpiritBear
        from bears.persistent import PolarsBear

        self.instance = (
            PolarsBear(name, pl.DataFrame())
            if bear_type == "polars"
            else SpiritBear(name)
        )

    def manifest(self) -> "PolarsBear":
        from bears.persistent import PolarsBear

        return PolarsBear(self.instance.name, pl.DataFrame())

bears/ephemeral.py

class SpiritBear:
    def __init__(self, name: str) -> None:
        self.name = name

    def __repr__(self):
        return f"SpiritBear({self.name})"

bears/persistent.py

import polars as pl


class PolarsBear:
    def __init__(self, name: str, data: pl.DataFrame) -> None:
        self.name = name
        self.data = data

    def __repr__(self):
        return f"PolarsBear({self.name}, {self.data})"

Actual error

>>> from bears import Bear
>>> bear = Bear("Berenstain")
>>> bear.manifest()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<@beartype(bears.core.Bear.manifest) at 0x105742340>", line 18, in manifest
  File "/Users/deepyaman/github/dagster-io/dagster/.venv/lib/python3.12/site-packages/beartype/_check/forward/reference/fwdrefmeta.py", line 148, in __instancecheck__
    return cls.__is_instance_beartype__(obj)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/deepyaman/github/dagster-io/dagster/.venv/lib/python3.12/site-packages/beartype/_check/forward/reference/fwdrefabc.py", line 111, in __is_instance_beartype__
    return isinstance(obj, cls.__type_beartype__)  # type: ignore[arg-type]
                           ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/deepyaman/github/dagster-io/dagster/.venv/lib/python3.12/site-packages/beartype/_check/forward/reference/fwdrefmeta.py", line 304, in __type_beartype__
    referee = import_module_attr(
              ^^^^^^^^^^^^^^^^^^^
  File "/Users/deepyaman/github/dagster-io/dagster/.venv/lib/python3.12/site-packages/beartype/_util/module/utilmodimport.py", line 295, in import_module_attr
    raise exception_cls(exception_message)
beartype.roar.BeartypeCallHintForwardRefException: Forward reference "PolarsBear" unimportable from module "bears.core".
>>>

Expected output

If I comment out the @beartype decorator:

>>> from bears import Bear
>>> bear = Bear("Berenstain")
>>> bear.manifest()
PolarsBear(Berenstain, shape: (0, 0)
┌┐
╞╡
└┘)

Activity

  1. leycec commented on Jan 8, 2025

    @leycec
    Member

    Ho, ho... ho. Yes. A most pertinent question. Indeed, we desperately need to document how to forward reference in a runtime-friendly way. I've been... tied up with other desperate codebase matters, sadly. CI has been on fire for the past several weeks, for example. Just put that one out! Phew. 🔥 🧯

    Thankfully, runtime-friendly references are super easy: just use PEP 484-compliant absolute stringified references (i.e., "."-delimited strings spelling out the absolute name of the package or module containing the user-defined class that is being referred to): e.g.,

    # In "bears/core.py":
    from typing import Optional
    
    import polars as pl
    from beartype import beartype
    
    # Note the absence of the "TYPE_CHECKING" antipattern here. Good!
    
    @beartype
    class Bear:
        def __init__(self, name: str, bear_type: Optional[str] = None) -> None:
            from bears.ephemeral import SpiritBear
            from bears.persistent import PolarsBear
    
            self.instance = (
                PolarsBear(name, pl.DataFrame())
                if bear_type == "polars"
                else SpiritBear(name)
            )
    
        # Absolute stringified references to the rescue. Thanks, PEP 484!
        def manifest(self) -> "bears.persistent.PolarsBear":  # <-- dot-delimited for the win
            from bears.persistent import PolarsBear
    
            return PolarsBear(self.instance.name, pl.DataFrame())

    Guido standardized this with PEP 484 almost a decade ago. When you're in Guido's hands, you know you're in good hands.

    Oddly, everybody uses the runtime-hostile TYPE_CHECKING forward reference antipattern instead. This is what the code you've so helpfully supplied above uses. That's the antipattern. It's an antipattern, because it breaks runtime type-checkers like @beartype, @typeguard, and Pydantic. TYPE_CHECKING evaluates to False at runtime. Runtime introspection has no valid means of inspecting imports isolated to TYPE_CHECKING blocks. As far as @beartype is concerned, those imports never happened... because they literally don't at runtime.

    Indeed, a future version of @beartype – probably beartype 0.20.1 ...so the next next stable release – will explicitly detect the TYPE_CHECKING forward reference antipattern and emit fatal warnings (or even exceptions) for each usage of this antipattern.

    Thanks so much for the detailed example, @deepyaman. I love that you even know about the Kermode spirit bears! We're so proud of them here in Canada. 🤗 🐻‍❄️ 🤗

  2. changed the title [-][Question] How can I make forward references with deferred imports work?[/-] [+][Feature Request] Detect and warn about the `TYPE_CHECKING` forward reference antipattern in `beartype.claw` import hooks :vomiting_face: [/+] on Jan 8, 2025
  3. deepyaman commented on Jan 9, 2025

    @deepyaman
    Author

    Thank you for the quick response! Glad to see it's a straightforward solution. I'll give it a shot in my actual code base and report back soon. 🤞

  4. leycec commented on Jan 9, 2025

    @leycec
    Member

    You're most welcome! Let me know if you have any other pressing Python QA needs. @beartype aims to please everyone. Together, we'll crush all the bugs. 😄

  5. TeamSpen210 commented on Jan 9, 2025

    @TeamSpen210

    Is that actually allowed? It probably should be since it's unambiguous and useful, but PEP 484 and the current version in the spec don't mention it as a possibility. Instead stringified annotations must be something that can eval() in the current scope. You'd need to make sure you've imported bears in this scope to let it work. Even then, I think mypy might be smart enough to notice that the submodule is not necessarily imported and complain, and I know Pyright deliberately doesn't allow implicitly referencing submodules like that because it might not have been loaded.

    Another solution is to keep the TYPE_CHECKING import, but then define a __getattr__() module hook to do the import, for runtime checkers to use.

  6. leycec commented on Jan 9, 2025

    @leycec
    Member

    ...heh. Standardization intensifies.

    Is that actually allowed?

    By @beartype? Sure! Absolutely. Of course. @beartype just makes up everything as it goes, anyway. @beartype has supported absolute stringified references for... Jesus. Has it really been a decade now? It really has. It's been a decade. What have I been doing with my life?

    Havin' fun. That's what! 😎

    ...but PEP 484 and the current version in the spec don't mention it as a possibility.

    It's PEP 563, actually. Consider this example from PEP 563:

    from __future__ import annotations
    import typing
    
    if typing.TYPE_CHECKING:
        import expensive_mod
    
    def a_func(arg: expensive_mod.SomeClass) -> None:
        a_var: expensive_mod.SomeClass = arg
        ...

    typing.TYPE_CHECKING unconditionally evaluates to False at runtime. Likewise, from __future__ import annotations activates PEP 563 which then stringifies the type hint expensive_mod.SomeClass to "expensive_mod.SomeClass". Altogether, this means that CPython reduces the above to the following at runtime:

    import typing
    
    def a_func(arg: 'expensive_mod.SomeClass') -> None:
        a_var: expensive_mod.SomeClass = arg
        ...

    This then implies that 'expensive_mod.SomeClass' is a valid forward reference to the type SomeClass defined by the top-level package or module expensive_mod.

    From @beartype's perspective, typing.TYPE_CHECKING and from __future__ import annotations basically don't exist. All that exists are stringified forward references. Ergo, @beartype transparently supports stringified forward references – because it has to. @beartype has no choice or say in the matter.

    From @beartype's perspective, there's no need to explicitly import anything. Whether users import things in if typing.TYPE_CHECKING: blocks is irrelevant to @beartype, because @beartype can't see those things. They're hidden from view. They'd might as well not exist in the first place.

    Given all of the above, why would there be any need to explicitly import anything in an if typing.TYPE_CHECKING: block, anyway? Hidden imports are just useless boilerplate. Absolute stringified references already convey all the metadata that @beartype and other runtime type-checkers need to unambiguously resolve forward references to external types.

    It's all kinda silly, honestly. Boilerplate is bad. Python isn't Java. We're supposed to be the agile anti-bureaucratic guys who wear flower-print Hawaiin t-shirts in the room. Nobody wears suits here.

    I think mypy might be smart enough to notice that the submodule is not necessarily imported and complain...

    I consider that stupidity rather than smart. Static type-checkers just waste everyone's time with this sort of pablum. There isn't really any error or issue with absolute stringified references. Static type-checkers just made up a mythical problem that doesn't actually exist.

    Oh, well. @beartype continues shrugging.

    Another solution is to keep the TYPE_CHECKING import, but then define a __getattr__() module hook to do the import, for runtime checkers to use.

    Clever, but also awful. Nobody would ever do that. That's the problem, right? If we consider this sort of kludge deeply and earnestly, we come to the unmistakable conclusion that nobody should ever do that.

    Seriously. That's maximum bureaucracy, boilerplate, and DRY violations. Can you imagine literally every single codebase that uses the typing.TYPE_CHECKING reference antipattern having to also now define one __getattr__() function per module just to satisfy... what? @beartype? Nobody cares about @beartype. Most third-party packages that don't actively use @beartype are tired of their userbase complaining about @beartype.

    The Ultimate Solution Emerges, Ultimately

    Ultimately, the solution is for @beartype to automate all of this away on behalf of users. When a user activates a beartype.claw import hook, an abstract syntax tree (AST) transformation in @beartype will:

    1. Detect the typing.TYPE_CHECKING reference antipattern.
    2. Issue one non-fatal warning for each if typing.TYPE_CHECKING: block containing one or more hidden imports. A warning is necessary, because:
    • @beartype can't automatically "repair" every instance of this antipattern. Star imports isolated to if typing.TYPE_CHECKING: blocks are unrepairable, for example. (In fact, @beartype may need to raise a fatal exception in this edge case.)
    • Many users still use @beartype as an explicit @beartype decorator rather than beartype.claw import hooks. This includes PyTorch. This antipattern will continue to break those codebases.
    • Many users use alternative runtime type-checkers or introspection APIs. Clearly, this antipattern will continue to break those codebases too.
    1. Automatically "repair" the antipattern by injecting one global-scoped @beartype forward reference per hidden import into the AST of the current module.

    Thus:

    # @beartype will transform this antipattern that doesn't work at runtime...
    if typing.TYPE_CHECKING:
        from A.B import C, D
    
    # ...into this comparable logic that actually *DOES* work at runtime.
    from beartype._check.forward.reference.fwdref import BeartypeForwardReference
    C = BeartypeForwardReference('A.B', 'C')
    D = BeartypeForwardReference('A.B', 'D')

    In the above example, C and D are now objects that transparently proxy isinstance() and issubclass() checks against those external types defined elsewhere.

    Fun stuff. I vomit. 🤮

  7. added a commit that references this issue on Jan 18, 2025
  8. changed the title [-][Feature Request] Detect and warn about the `TYPE_CHECKING` forward reference antipattern in `beartype.claw` import hooks :vomiting_face: [/-] [+][Feature Request] `beartype.claw` import hooks transform runtime-hostile `TYPE_CHECKING` forward reference antipattern to runtime-friendly @beartype forward references = 🥹 [/+] on Nov 15, 2025
  9. leycec commented on Nov 15, 2025

    @leycec
    Member

    As a friendly note to both myself and Claude, hey there, buddy. got tokens? the above transformation isn't quite enough:

    # @beartype will transform this antipattern that doesn't work at runtime...
    if typing.TYPE_CHECKING:
        from A.B import C, D
    
    # ...into this comparable logic that actually *DOES* work at runtime.
    from beartype._check.forward.reference.fwdref import BeartypeForwardReference
    C = BeartypeForwardReference('A.B', 'C')
    D = BeartypeForwardReference('A.B', 'D')
    
    # Let's try instantiating these objects! It's fun!
    see_c_run = C()
    see_d_die = D()

    Why? Because the BeartypeForwardReference() class currently prohibits instantiation. We thought we were doing a good thing. The above logic currently raises unreadable exceptions resembling:

    beartype.roar.BeartypeDecorHintForwardRefException: <forwardref
    BeartypeForwardRefABC(__name_beartype__=None, __scope_name_beartype__=None)> subclass
    <forwardref C(__name_beartype__='C', __scope_name_beartype__='A.B')> not
    instantiatable.
    

    In other words, @beartype forward references are not yet suitable proxies for external types. They could be. They should be. But they fail to transparently act like external types in all possible contexts. Maybe that's fine, though? No idea. Surely, anybody importing types in a if TYPE_CHECKING: blocks knows that those types are mostly unusable? No idea.

    I suppose we could just ignore the fact that @beartype forward references are imperfect proxies by just gating all of this AST transformation logic behind yet another:

    • New BeartypeConf(is_proxy_type_checking_imports: bool = True) configuration setting, defaulting to True because almost everybody wants this to just work already.

    Sounds good! Let's do something before the Universe implodes! 🪐 💥

  10. Glinte commented on Dec 9, 2025

    @Glinte
    Contributor

    New BeartypeConf(is_proxy_type_checking_imports: bool = True) configuration setting, defaulting to True because almost everybody wants this to just work already.

    I just want to point out that if this defaults to True, there is a possibility of things mysteriously failing when you stop using beartype. This feature is like useful enough that this may be overall good, but it still feels weird.

  11. leycec commented on Dec 10, 2025

    @leycec
    Member

    ...there is a possibility of things mysteriously failing when you stop using beartype.

    Indeed. It's even worse than that, though. @beartype itself stops everyone from using @beartype by silently reducing to a noop under Python optimization (e.g., ${PYTHONOPTIMIZE}, -O). When Python is optimized, @beartype effectively no longer exists. That includes beartype.claw import hooks and thus this feature.

    You're absolutely right, @Glinte. Why are you like this!? It's like you're a living QA oracle. The Greeks prophesied your coming. Caution is thus warranted. To preserve compatibility across @beartype logic paths and prevent vendor lock-in to @beartype, beartype.claw import hooks (probably) only want to conditionally perform this transformation when type hints are lazily evaluated. Fortunately, that's increasingly the default state of typing. Hints are lazily evaluated under one of two scenarios:

    • Under any Python version, when the user explicitly enables PEP 563 for the current submodule via from __future__ import annotations. This is trivial to detect from the AST. In fact, we already do just that! Laziness prevails. \o/
    • Under Python ≥ 3.14. Always. This is even more trivial to detect, obviously.

    We can thus trivially decide at AST transformation time whether we're going to conditionally commit to performing this transform or not. If one of the above two constraints is met, we respect the user-configured conf.is_proxy_type_checking_imports option and do what the user tells us; else, we ignore the user's preference entirely.

    Seems reasonable to me, but... video games also seem reasonable to me. They are not.

    What's your intuitive read on this sort of QA skullduggery, Master @Glinte? Does lazy evaluation of type hints suffice to render this fully safe... or do we need to dig even deeper? Are edge cases lurking like slathering spiders in the gloom? You can almost hear their mandibles chewing. Is anyone safe anymore? Is nothing sacred? I implore you to answer the questions of the Universe.

  12. Glinte commented on Dec 10, 2025

    @Glinte
    Contributor

    Edit: Actually if you disallow instantiation as in the current behaviour it should be fine. This does mean probably some third party library that tries to instantiate stuff inside type hints will fail, and hints like Annotated[int, GreaterThan(3)] will fail if GreaterThan is a BeartypeForwardReference, but this seems reasonable.

    Original comment below


    Probably not going to suffice for sufficiently bad misuse of if TYPE_CHECKING. I can imagine some newbie doing

    if TYPE_CHECKNG:
        from a import MyClass
    
    x = MyClass()

    This is trivially a NameError in runtime and will get caught by everyone, until you use beartype and it becomes functional.

  13. black-snow commented on Aug 6, 2026

    @black-snow

    I couldn't be bothered to read the full thread but I think I ran into a similar issue. Tried beartype an ran into some weird

    BeartypeDecorHintForwardRefException: Forward reference 'Mapping[annotationlib_name_1, annotationlib_name_2]' not valid Python attribute name

    errors that didn't make much sense to me. After fiddling around a bit I found that I have to remove all

    if TYPE_CHECKING:
        import ...

    from my codebase to make them go away.
    If this is intended we should at least have an error message hinting at this. Or at the very least an FAQ entry that's <enter your search engine here>-able.

  14. leycec commented on Aug 6, 2026

    @leycec
    Member

    Totally! In all likelihood, all those super-annoying forward references shenanigans you've hit have already been resolved in the live @beartype codebase. But...

    Maybe not, actually. if TYPE_CHECKING: is still runtime-hostile and @beartype still has yet to explicitly do anything about that. What a shame, huh? I totally neglected this critical issue that's been annoying everyone for years.

    You're absolutely right, @black-snow. Great avatar name, by the way. This oddness should be (A) publicly documented somewhere and (B) just resolved already. I'm so sorry you've stumbled into all of those roadblocks with @beartype. Certainly, we are imperfect. Someday, this software will do everything everyone wants. Clearly, this is not that day. 😮‍💨

  15. leycec commented on Aug 7, 2026

    @leycec
    Member

    What follows is my message in a bottle to my older self. No one else may care. Older self, you are finally trying to resolve this issue, huh? Good on ya. I have good news and no bad news, really. The good news is this:

    PEP 484-style if TYPE_CHECKING: lazily imported type hints, on the other hand, are not trivially supportable by anybody at runtime. It's unclear how or when @beartype will be capable of that. The conclusion for end users is clear:

    • Packages willing to require Python ≥ 3.15 as a mandatory dependency should (A) do that and (B) refactor all PEP 484-style if TYPE_CHECKING: lazily imported type hints into equivalent PEP 810-style lazily imported type hints:

      # Instead of this...
      from typing import TYPE_CHECKING
      if TYPE_CHECKING:
         from typing import Literal, Self, ThisIsHellOnRuntimeEarth
      
      # ...everybody who loves Python 3.15 should instead do this.
      lazy from typing import Literal, Self, ThisIsHellOnRuntimeEarth
    • Packages refusing to require Python ≥ 3.15 as a mandatory dependency should instead refactor all PEP 484-style if TYPE_CHECKING: lazily imported type hints into equivalent PEP 484-style stringified absolute forward references.

      # Instead of this...
      from typing import TYPE_CHECKING
      if TYPE_CHECKING:
         from typing import Literal, Self, ThisIsHellOnRuntimeEarth
      
      # ...everybody who hates Python 3.15 should instead do this.
      Literal = 'typing.Literal'
      Self = 'typing.Self'
      ThisIsHellOnRuntimeEarth = 'typing.ThisIsHellOnRuntimeEarth'

    The Python ≥ 3.15 approach is blatantly superior (both from a syntactic and semantic perspective), but also requires Python ≥ 3.15. Boooooooooo. 😯

    The only remaining question then becomes...

    So. Uh. What Should @beartype Do?

    I'm fairly certain that there exists a clean one-to-one mapping between PEP 484-style if TYPE_CHECKING: and PEP 810-style lazily imported type hints. That is, given one or more imports isolated to an if TYPE_CHECKING: branch, @beartype can (and should) safely transform those into the equivalent lazy imports via a simple AST transform.

    The same cannot be said of PEP 484-style stringified absolute forward references. You can't subscript them or | them, for example. Ergo, beartype.claw import hooks should now do one of two things when it detects imports isolated to an if TYPE_CHECKING: branch at AST transformation time:

    • If the active Python interpreter targets Python ≥ 3.15, just transparently convert those imports into the equivalent PEP 810-style lazy imports (like above). There's no cause for issuing warnings or raising exceptions here. If @beartype can simplify everyone's life, it should. 💪 🐻
    • Else, @beartype should issue a non-fatal warning whose message is verbatim the text above starting "The conclusion for end users is clear: ...".

    And that's how the if TYPE_CHECKING: was won. By doing nothing for a decade, waiting for CPython devs to fix the mess, and then pointing at a PEP standard. Feels good, fam.


    what is even happening on this issue tracker anymore

  16. changed the title [-][Feature Request] `beartype.claw` import hooks transform runtime-hostile `TYPE_CHECKING` forward reference antipattern to runtime-friendly @beartype forward references = 🥹 [/-] [+][Feature Request] `beartype.claw` import hooks transform runtime-hostile `if TYPE_CHECKING:` antipattern to runtime-friendly PEP 810 `lazy` imports = 🥹 [/+] on Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions