2.8 - Type Hints and Generics
Python remains a dynamically typed language, but since Python 3.5 (PEP 484) it supports optional type hints: annotations that document the expected types of variables, parameters, and return values. Type hints are not enforced at runtime — Python ignores them when executing your code — but tools like mypy, pyright, and modern IDEs use them to catch type errors before you run anything.
2.8.1 - Annotating Variables and Functions
Variable annotations use a colon after the name; function annotations use a colon after each parameter and an arrow (->) before the return type:
age: int = 30
name: str = "Alice"
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
Since Python 3.10 (PEP 604), you can express "this type or None" (and unions in general) with | instead of typing.Optional and typing.Union:
def find_user(user_id: int) -> "User | None":
...
def parse(value: int | str) -> float:
...
2.8.2 - Generic Containers
Since Python 3.9 (PEP 585), the built-in collection types can be used directly as generics, without importing typing.List, typing.Dict, and similar aliases:
def average(values: list[float]) -> float:
return sum(values) / len(values)
def word_counts(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
2.8.3 - Protocols and Structural Typing
typing.Protocol (Python 3.8+, PEP 544) defines a structural type — an interface based on what an object can do, not what class it inherits from. Any object with matching methods satisfies the protocol, without explicit inheritance:
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
class DatabaseConnection:
def close(self) -> None:
print("Connection closed")
def shut_down(resource: SupportsClose) -> None:
resource.close()
shut_down(DatabaseConnection()) # Type-checks: DatabaseConnection has a matching close()
This is Python's version of "duck typing," made explicit enough for static type checkers to verify.
2.8.4 - Function Overloads
@typing.overload (Python 3.5+) documents multiple valid call signatures for a single function whose behavior depends on the type of its arguments. The overload declarations are only used by type checkers; the real implementation is the final, undecorated definition:
from typing import overload
@overload
def process(value: int) -> int: ...
@overload
def process(value: str) -> str: ...
def process(value):
if isinstance(value, int):
return value * 2
return value.upper()
A type checker sees process(5) as returning int and process("hi") as returning str, even though there's only one real function body.
2.8.5 - PEP 695 Generic Syntax (Python 3.12)
Prior to Python 3.12, writing a generic function or class required an explicit TypeVar declared outside the function or class:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
Python 3.12 (PEP 695) introduces a more compact, built-in syntax where the type parameter is declared in square brackets right after the function or class name, scoped to that definition:
def first[T](items: list[T]) -> T:
return items[0]
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
The same syntax also introduces the type statement for declaring type aliases, replacing typing.TypeAlias:
type Point = tuple[float, float]
def distance(a: Point, b: Point) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
Type aliases declared with type can be generic too:
type Pair[T] = tuple[T, T]
2.8.6 - Deferred Evaluation of Annotations (Python 3.14)
Historically, annotations that referred to a class not yet defined (a "forward reference") had to be written as a string:
class Node:
def __init__(self, value: int, next: "Node | None" = None) -> None:
self.value = value
self.next = next
With Python 3.14 (PEP 649 and PEP 749), annotations are no longer evaluated eagerly when a function, class, or module is defined. Instead, they're stored in a lazily-called annotate function and only evaluated on demand, so forward references no longer need to be quoted:
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next
The new annotationlib module provides tools for reading these deferred annotations at runtime, without triggering eager evaluation:
import annotationlib
def greet(name: str) -> str:
return f"Hello, {name}"
print(annotationlib.get_annotations(greet))
# {'name': <class 'str'>, 'return': <class 'str'>}
Code that reads __annotations__ directly still works in most cases, but annotationlib.get_annotations() is the more robust choice going forward, since it can request annotations in a string or forward-reference-safe form instead of forcing full evaluation.
2.8.7 - Summary
| Feature | Syntax | Since |
|---|---|---|
| Basic annotations | def f(x: int) -> str: | 3.5 |
| Union shorthand | int | None | 3.10 |
| Built-in generic containers | list[int], dict[str, int] | 3.9 |
| Structural typing | class C(Protocol): | 3.8 |
| Function overloads | @overload | 3.5 |
| Compact generic syntax | def f[T](x: T) -> T:, class C[T]: | 3.12 |
| Type alias statement | type Point = tuple[float, float] | 3.12 |
| Deferred annotation evaluation | (automatic — no quoting needed) | 3.14 |
Type hints don't change how your program runs, but they document intent, catch bugs earlier when paired with a type checker, and make large codebases easier to navigate.