Type Hints
A type hint (param: Type, -> ReturnType, name: Type) documents the intended type. Python's interpreter does not enforce it at runtime — add("x", "y") runs "x" + "y" happily and returns "xy", no error, no warning. Hints exist for static analysis tools (mypy, pyright), IDEs (autocomplete, inline errors), and human…
🎯 What you'll learn
- Basic Annotations
- Optional and Union Types
- Generic Containers
TypeVar— Generic FunctionsProtocol— Structural Typing
1Basic Annotations
def add(a: int, b: int) -> int:
return a + b
name: str = "ana"
age: int
age = 30A type hint (param: Type, -> ReturnType, name: Type) documents the intended type. Python's interpreter does not enforce it at runtime — add("x", "y") runs "x" + "y" happily and returns "xy", no error, no warning. Hints exist for static analysis tools (mypy, pyright), IDEs (autocomplete, inline errors), and human readers — not for the runtime.
2Optional and Union Types
from typing import Optional, Union
def find_user(user_id: int) -> Optional[dict]: # equivalent to Union[dict, None]
...
def parse(value: Union[int, str]) -> int: # accepts EITHER an int or a str
...
# Python 3.10+ pipe syntax — equivalent, generally preferred in new code
def find_user_new(user_id: int) -> dict | None:
...
def parse_new(value: int | str) -> int:
...Optional[X] is exactly Union[X, None] — a value that is either type X or None. Union[A, B, ...] means "any one of these types." The | syntax (X | None, A | B) does the same thing with less ceremony and no import needed, available from Python 3.10 onward.
3Generic Containers
def total(nums: list[int]) -> int:
return sum(nums)
def lookup(mapping: dict[str, int], key: str) -> int | None:
return mapping.get(key)
def first(items: tuple[int, ...]) -> int: # variable-length homogeneous tuple
return items[0]
def pair() -> tuple[str, int]: # fixed-shape tuple: exactly (str, int)
return ("ana", 30)Built-in generics (list[int], dict[str, int], tuple[...], set[str]) describe not just the container type but what it contains — list[int] means "a list whose elements are ints," which static checkers use to catch, e.g., appending a string to a list[int]. (Python 3.9+ supports this directly on the built-ins; older code uses typing.List[int] etc., which still works but is considered legacy style.)
4TypeVar — Generic Functions
from typing import TypeVar
T = TypeVar("T")
def first_item(items: list[T]) -> T:
return items[0]
x: int = first_item([1, 2, 3]) # a type checker infers T=int here
y: str = first_item(["a", "b"]) # and T=str hereTypeVar lets a function's input and output types be linked generically — a type checker infers a concrete type for T at each call site based on the argument passed, and checks that the return type is consistent with that same T. Without it, first_item(items: list) -> object would type-check but lose the connection between "what went in" and "what comes out."
5Protocol — Structural Typing
from typing import Protocol
class SupportsQuack(Protocol):
def quack(self) -> str: ...
class Duck:
def quack(self) -> str:
return "Quack!"
class Person:
def quack(self) -> str:
return "I'm quacking!"
def make_it_quack(entity: SupportsQuack) -> str:
return entity.quack()
make_it_quack(Duck()) # OK — has a matching quack() method
make_it_quack(Person()) # ALSO OK — Person never inherits from SupportsQuack,
# but it structurally matches (duck typing, formalized)Protocol defines an interface by shape (which methods/attributes must exist) rather than by explicit inheritance — any class whose objects have a matching quack() -> str method satisfies SupportsQuack, with zero coupling to it. This formalizes Python's long-standing "duck typing" convention (if it walks like a duck and quacks like a duck...) in a way static checkers can actually verify.
6TYPE_CHECKING — Avoiding Circular Imports for Hints Only
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from other_module import OtherClass # only imported by type checkers, never at runtime
def process(item: "OtherClass") -> None: # quoted, since OtherClass isn't actually
... # imported when this code really runstyping.TYPE_CHECKING is False at runtime and True only from a static type checker's perspective — so imports needed purely for annotations (and nowhere else in the function bodies) can be guarded behind it, avoiding a real circular-import failure at runtime while still giving the checker what it needs. The annotation itself is typically written as a string ("OtherClass", a "forward reference") since the name genuinely doesn't exist at runtime under this guard.
Under the HoodHints Are Erased at Runtime▾
def add(a: int, b: int) -> int:
return a + b
print(add.__annotations__) # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
print(add("x", "y")) # "xy" — runs fine, the hints were never checkedAnnotations are stored in a function's __annotations__ dict (or a class's, for variable annotations) purely as metadata — nothing in the interpreter consults them during a normal call. This is why add("x", "y") executes without error: type hints are a documentation and tooling layer bolted onto a dynamically-typed language, not a language-level enforcement mechanism (contrast with a statically-typed language, where this would be a compile error). Enforcement, when wanted, comes from a separate tool (mypy, pyright) run as a linting step, or from a library (like pydantic) that explicitly validates values against annotations at runtime — the language itself stays silent.
⚠️ Common Pitfalls
- Assuming type hints prevent runtime type errors — they don't; nothing stops
add("x", "y")from running. Enforcement requires a separate static checker or explicit runtime validation. - Forgetting
Optional/| Nonewhen a parameter or return value can genuinely beNone— annotatingdef find(id: int) -> dictwhen the function can returnNoneon a miss gives a static checker false confidence and misleads readers. - Circular imports from unguarded hint-only imports — importing a class solely to annotate a parameter, without
TYPE_CHECKING, can create a real circular-import failure that wouldn't exist if the import were only needed for hints. - Using
typing.List/typing.Dictin new code — the built-ins (list[int],dict[str, int]) have supported subscripting since Python 3.9 and are now preferred; thetypinggeneric aliases are legacy but still work for older codebases/Python versions. - Expecting
Protocolto require explicit inheritance like an ABC — the whole point is structural matching; a class satisfies aProtocolby having the right shape, not by subclassing it (though it can subclass it too, which is allowed but not required).
📋 Quick Reference
def f(a: int, b: str = "x") -> bool: ... # basic parameter/return annotations
x: list[int] = [] # generic container annotation
from typing import Optional, Union
Optional[X] # same as X | None
Union[A, B] # same as A | B
X | None # 3.10+ pipe syntax, preferred
from typing import TypeVar
T = TypeVar("T")
def first(xs: list[T]) -> T: ... # generic function
from typing import Protocol
class HasName(Protocol):
name: str # structural interface
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mod import Thing # hint-only import, no runtime cost
func.__annotations__ # where hints actually live — inspected, never enforced, by Python itself🎤 Interview Q&A
Q1Why use type hints in Python?core▾
They document intent, enable static checking (mypy, pyright) to catch bugs before runtime, power editor tooling, and drive frameworks like FastAPI and Pydantic. They don't enforce types at runtime by themselves.
- Runtime validation? Pydantic or similar.
Q2What are common typing constructs?core▾
Built-in generics (list[int], dict[str, User]), X | None (Optional), unions, Callable, TypedDict, Literal, Protocol, TypeVar/generics (def first[T](xs: list[T]) -> T in 3.12), Final, Self.
- Any vs object? Any disables checks; object requires narrowing.
Q3What is a Protocol?core▾
A structural type: any class with matching methods satisfies it without inheritance, bringing duck typing to static checking.
- runtime_checkable? Allows isinstance checks (methods only).
Q4How do you adopt typing in a large existing codebase?senior▾
Gradually: start with public APIs and new code, enable stricter mypy/pyright settings per module, add stubs for libraries, and track coverage; avoid blanket Any.
- Strict options?
disallow_untyped_defs,strict = trueper package.
Q5TypedDict vs dataclass vs Pydantic model?core▾
TypedDict types dict shapes (e.g. JSON) without runtime cost; dataclasses create real classes with generated methods; Pydantic models validate and parse data at runtime.
- Which for API input? Pydantic.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.