Type hints are optional annotations that document expected types. They are NOT enforced at runtime by the interpreter itself, but are checked by static analysis tools like mypy or pyright, and improve editor autocomplete significantly.
Basic Syntax
def add(a: int, b: int) -> int: return a + bname: str = "Bob"age: int = 25is_active: bool = True
Type hints are not enforced at runtime
def add(a: int, b: int) -> int: return a + badd("hello", "world") # runs FINE at runtime, returns 'helloworld', no error!
To catch this, you need to actually run a type checker: mypy your_file.py.
Built-in Generic Types (Python 3.9+ syntax)
def process(items: list[int]) -> dict[str, int]: ...def get_config() -> dict[str, str | int]: # union type using | ...coordinates: tuple[float, float] = (12.9, 77.6)names: set[str] = {"a", "b"}
typing Module (Needed for Python < 3.9, or More Advanced Constructs)
from typing import List, Dict, Tuple, Set, Optional, Union, Any, Callabledef process(items: List[int]) -> Dict[str, int]: # older style, pre-3.9 ...def find_user(user_id: int) -> Optional[str]: # Optional[X] means X or None ... # equivalent to Union[X, None] or X | Nonedef combine(a: Union[int, str]) -> str: # accepts either type ...def apply(func: Callable[[int, int], int], a: int, b: int) -> int: # function type return func(a, b)value: Any = "could be literally anything" # opts OUT of type checking for this variable
Optional[X] vs X | None
Optional[str] and str | None mean exactly the same thing. The | syntax (Python 3.10+) is the modern preferred style; Optional from typing is needed for older Python versions or when the codebase hasn’t migrated yet.
TypedDict: Typed Dictionary Shapes
from typing import TypedDictclass UserDict(TypedDict): name: str age: int active: booldef create_user(data: UserDict) -> None: print(data["name"])create_user({"name": "Bob", "age": 25, "active": True}) # type-checker validates the shape
Lets a function or class work with any type while preserving the relationship between input and output types.
from typing import TypeVar, ListT = TypeVar("T")def first(items: List[T]) -> T: return items[0]first([1, 2, 3]) # type checker infers return type as intfirst(["a", "b"]) # type checker infers return type as str
Final: Declaring True Constants
from typing import FinalMAX_RETRIES: Final = 5MAX_RETRIES = 10 # mypy flags this as an error: cannot reassign a Final variable
Type Aliases
from typing import Dict, ListUserId = intUserDatabase = Dict[UserId, str]def lookup(db: UserDatabase, uid: UserId) -> str: return db[uid]# Python 3.12+ has a dedicated syntax:type UserDatabase = dict[int, str]
Checking Types at Runtime (When You Actually Need To)
Type hints themselves don’t enforce anything, but isinstance() still works normally alongside them for genuine runtime checks:
def process(value: int | str) -> str: if isinstance(value, int): return str(value * 2) return value.upper()
Running a Type Checker
pip install mypymypy my_script.py
When type hints earn their keep
Small scripts rarely need them. Larger codebases, especially ones with multiple contributors or long-lived APIs, benefit enormously: hints double as documentation, catch entire classes of bugs before runtime, and make refactoring far safer.