2025, Oct 05 05:00

Making get_type_hints work with TYPE_CHECKING: load guarded imports and resolve Python annotations for schema generation

Fix get_type_hints failures from TYPE_CHECKING: detect and import guarded names with libcst, merge globals, and resolve Python annotations for runtime generation

Generating runtime schemas from Python type hints works smoothly until you meet annotations guarded by TYPE_CHECKING. At that point, introspection with get_type_hints starts failing because the names you rely on simply do not exist at runtime. Below is a practical walkthrough of the failure mode and a concrete approach to make get_type_hints aware of those annotations without altering the original source.

Problem overview

You have a class defined in a separate file. Its methods are annotated with types that are only imported inside an if TYPE_CHECKING block. You then try to load that file dynamically and extract type hints from its functions. The call to get_type_hints breaks because names used in annotations aren’t available at runtime.

Minimal failing example

The class lives in a separate module and uses an import that is only present for type checking:

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    import qxx
class Gadget:
    def do_work(self) -> qxx: ...

Somewhere else you import the module, locate functions, and attempt to resolve their annotations:

from importlib import import_module
import inspect
from typing import get_type_hints
mod_obj = import_module("gadget_module")
for fn in inspect.getmembers(Gadget, predicate=inspect.isfunction).values():
    get_type_hints(fn, globalns=mod_obj.__dict__)

This fails because qxx is undefined. The import protected by TYPE_CHECKING never executes at runtime, so get_type_hints cannot resolve the name. As noted elsewhere, get_type_hints attempts to resolve forward references and raises when it cannot, which is exactly what happens here.

Why this happens

TYPE_CHECKING gates imports so they are evaluated by type checkers but not at runtime. When get_type_hints tries to evaluate annotations, it needs all referenced names present in the provided globalns or localns. Since qxx is never imported at runtime, the symbol is missing and resolution fails. This is tightly coupled to how get_type_hints eagerly resolves annotation expressions, including forward references.

A practical fix: pre-load TYPE_CHECKING-only imports and pass them to get_type_hints

The reliable way forward is to parse the target file, detect imports located under if TYPE_CHECKING, import those modules or objects yourself, and then pass the resulting mapping into get_type_hints via globalns. This keeps the original source untouched and equips get_type_hints with everything it needs for resolution.

The approach below uses libcst to find imports within the TYPE_CHECKING block and materialize them into a dictionary that maps alias names to imported objects or modules.

import importlib
from pathlib import Path
from typing import Any
import libcst as cst
def collect_typing_guarded_imports(file_loc: str) -> dict[str, Any]:
    """
    Locate all imports under an `if TYPE_CHECKING:` block in the given file
    and import them so they can be injected into get_type_hints.
    """
    class ImportAccumulator(cst.CSTVisitor):
        def __init__(self) -> None:
            self.bucket: list[cst.Import | cst.ImportFrom] = []
        def visit_Import(self, node: cst.Import) -> None:
            self.bucket.append(node)
        def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
            self.bucket.append(node)
    class TCImportsScanner(cst.CSTVisitor):
        def __init__(self) -> None:
            self.matches: list[cst.Import | cst.ImportFrom] = []
        def visit_If(self, node: cst.If) -> bool:
            if isinstance(node.test, cst.Name) and node.test.value == "TYPE_CHECKING":
                acc = ImportAccumulator()
                node.body.visit(acc)
                self.matches.extend(acc.bucket)
                return False
            return True
    unit = cst.parse_module(Path(file_loc).read_text(encoding="utf8"))
    scout = TCImportsScanner()
    unit.visit(scout)
    resolved: dict[str, Any] = {}
    for item in scout.matches:
        if isinstance(item, cst.Import):
            # import pandas as pd
            for alias in item.names:
                mod_name = unit.code_for_node(alias.name)
                imported = importlib.import_module(mod_name)
                alias_name = (
                    unit.code_for_node(alias.asname.name)
                    if alias.asname
                    else mod_name
                )
                resolved[alias_name] = imported
        else:
            # from dataclasses import dataclass as dc
            pkg_name = unit.code_for_node(item.module)
            imported_pkg = importlib.import_module(pkg_name)
            for alias in item.names:
                origin = unit.code_for_node(alias.name)
                alias_name = (
                    unit.code_for_node(alias.asname.name)
                    if alias.asname
                    else origin
                )
                resolved[alias_name] = getattr(imported_pkg, origin)
    return resolved

Once you have this mapping, combine it with the module’s globals and pass it to get_type_hints. This gives the resolver visibility into all names that would otherwise exist only for static type checking.

from importlib import import_module
import inspect
from typing import get_type_hints
# Load the module that defines the class with TYPE_CHECKING-guarded imports
mod_obj = import_module("gadget_module")
# Introspect additional names that appear only under TYPE_CHECKING in that file
extra_ns = collect_typing_guarded_imports(mod_obj.__file__)
# Merge namespaces and resolve hints successfully
merged_ns = {**mod_obj.__dict__, **extra_ns}
for fn in inspect.getmembers(Gadget, predicate=inspect.isfunction).values():
    hints = get_type_hints(fn, globalns=merged_ns)
    # Use `hints` to build your schema

Why this matters

When schema generation or any form of runtime introspection depends on annotations, a missing import under TYPE_CHECKING can derail the entire pipeline. Making resolution explicit keeps your tooling resilient. It acknowledges that annotations may refer to names that aren’t present at runtime and ensures your process still completes without changing the original source. As was aptly observed, get_type_hints will attempt to resolve forward references and raise when it cannot, so pre-populating the resolution context is critical. There is also discussion around PEPs 563 and 749 in this area, but the approach above stays focused on making the current resolution succeed with the code you have.

Takeaways

If you need to resolve annotations that depend on TYPE_CHECKING-only imports, first extract those imports programmatically and load them, then provide them to get_type_hints through globalns. This simple addition makes your schema generation reliable without touching the module under analysis. Keep a clear separation between source code and tooling: let the source keep its TYPE_CHECKING guards, and let the tooling bridge the runtime gap.

The article is based on a question from StackOverflow by XXXHHHH and an answer by XXXHHHH.