65.8 Annotated, overload, and TYPE_CHECKING
Alright, let’s get into the weeds on three features that separate the typing hobbyists from the architects. These aren’t for your basic variable annotations; they’re the tools you use when the type system needs to get out of its own way to describe your actual, sometimes messy, code.
The Annotated Type: Putting Metadata in the Margins
Sometimes, a type alone just isn’t enough. You need to attach a little extra context—a string label, a range constraint, some configuration hint—that’s important for other tools (like a validation library or a web framework) but means absolutely nothing to the actual Python type checker. That’s what Annotated is for.
Think of it as a way to smuggle notes past the type checker bouncer. The checker only looks at the first argument (the actual type) and blissfully ignores the rest.
from typing import Annotated
from pydantic import Field, BaseModel
# Without Annotated, you'd have to use a separate tool-specific class.
# This tells Pydantic "this is a str, and by the way, make it this fancy field"
def create_user(username: Annotated[str, Field(max_length=32, description="Your login handle")]) -> None:
...
# The type checker sees this as just 'str'. The rest is for Pydantic's eyes only.
It’s also fantastic for creating type aliases that carry semantic meaning without creating a whole new class, which is often overkill.
from typing import Annotated
import decimal
# A more descriptive alias that tools can interrogate.
Meters = Annotated[decimal.Decimal, "unit of length in meters"]
Seconds = Annotated[decimal.Decimal, "unit of time in seconds"]
def calculate_velocity(distance: Meters, time: Seconds) -> Annotated[decimal.Decimal, "meters per second"]:
return distance / time
# The code is clear, and the types are still just Decimal under the hood.
@overload: Telling the Type Checker, “It Depends”
Here’s the truth: union types are often a cop-out. If you have a function def parse(input: str | bytes) -> MyData:, the return type is always MyData. But what if the return type actually depends on the input type? Enter @overload. This decorator doesn’t change runtime behavior at all; it exists purely to give the type checker a more precise map of your function’s logic.
You use it to describe distinct input/output scenarios, followed by a final, actual implementation that the type checker will conveniently ignore.
from typing import overload, Literal
@overload
def parse_number(value: str) -> float: ...
@overload
def parse_number(value: int) -> Literal[42]: ... # A joke for the type checker.
# The actual implementation signature is a union, which is often less precise.
def parse_number(value: str | int) -> float:
if isinstance(value, int):
return 42.0 # Clearly the best number.
return float(value)
# Now the type checker knows the output type is tied to the input.
result_str: float = parse_number("3.14") # OK
result_int: float = parse_number(10) # OK, but the checker knows it's literally 42.0
The most important rule: your implementation signature must be compatible with all the overloads. This is how you describe complex functions like dict.get without the type checker just giving up and saying Any.
TYPE_CHECKING: Hiding Your Dirty Laundry
This one is a life-saver for import cycles. You know the drill: you need a type hint for a class that’s defined later in the file, or in another module that imports the current one. If you try to import it normally, Python screams at you about circular imports.
TYPE_CHECKING is a special constant that is always False at runtime, but type checkers pretend it’s True. This lets you put imports inside an if TYPE_CHECKING: block. At runtime, the import never happens, so no cycle. During type checking, the import is processed, so the hint is available.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from expensive_module import BigComplexThing # This import won't happen at runtime!
def create_thing() -> "BigComplexThing | None":
# At runtime, we avoid importing 'expensive_module' unless absolutely necessary.
try:
from expensive_module import BigComplexThing # Runtime import here, inside function
return BigComplexThing()
except ImportError:
return None
Notice I also used a forward reference ("BigComplexThing | None") for the return type. This is often paired with TYPE_CHECKING because the class isn’t available for a regular hint at the time the function is defined. The quotes are crucial. It’s a bit clunky, but it’s the price we pay for structuring code that doesn’t make the interpreter have a meltdown. Use this pattern liberally in __init__.py files and anywhere else import cycles like to lurk.