Writing Generic Type Annotations
Python# Modern (Python 3.9+): use built-in collection types directly def process(items: list[int]) -> dict[str, int]: ... # Modern (Python 3.10+): use | for unions instead of typing.Union/Optional def find(key: str) -> int | None: ... # Avoid legacy forms unless supporting Python < 3.9 / < 3.10 from typing import List, Dict, Union, Optional # legacy, avoid in new code
Progress:
- Step 1: Determine minimum supported Python version for the codebase
- Step 2: Choose generic alias style (built-in vs
typingmodule) based on version - Step 3: Choose union style (
|vstyping.Union/Optional) based on version - Step 4: Apply consistently across the file/module
- Step 5: Verify with a type checker (mypy/pyright) since these are compile-time-only checks
Step 1 — Version check:
- Python >= 3.9: built-in generics (
list[int],dict[str, int],tuple[int, ...],set[str]) work at runtime. - Python >= 3.10:
X | Yunion syntax works at runtime (isinstancechecks,int | None). - Python < 3.9: must use
typing.List,typing.Dict, etc. - Python < 3.10 but >= 3.7 with
from __future__ import annotations: can use both new syntaxes in annotations (they're stored as strings, not evaluated), but not in runtime contexts likeisinstance(x, int | str).
Step 2 — Generic alias mapping:
Legacy (typing) | Modern (built-in) |
|---|---|
List[int] | list[int] |
Dict[str, int] | dict[str, int] |
Tuple[int, str] | tuple[int, str] |
Set[int] | set[int] |
FrozenSet[int] | frozenset[int] |
Type[C] | type[C] |
Step 3 — Union mapping:
| Legacy | Modern |
|---|---|
Union[int, str] | int | str |
Optional[int] | int | None |
Union[int, str, None] | int | str | None |
Step 4 — Apply consistently. Don't mix List[int] and dict[str, int] in the same file — pick one style per codebase, driven by the minimum supported version.
Step 5 — Verify. Built-in generics and | unions are purely typing-time constructs; run mypy or pyright to catch mistakes, since Python itself won't validate annotation contents at runtime beyond syntax.
Example 1:
Input: def get_user(id: int) -> Optional[User]: in a Python 3.11 codebase
Output: def get_user(id: int) -> User | None:
Example 2:
Input: def merge(a: Dict[str, List[int]], b: Dict[str, List[int]]) -> Dict[str, List[int]]: targeting Python 3.9+
Output: def merge(a: dict[str, list[int]], b: dict[str, list[int]]) -> dict[str, list[int]]:
Example 3:
Input: A library must support Python 3.8 as minimum
Output: Keep from typing import List, Dict, Optional, Union and legacy syntax throughout; do not introduce list[int] or X | Y since they'll raise TypeError at runtime on 3.8.
Example 4:
Input: isinstance(value, Union[int, float]) — runtime isinstance check
Output: isinstance(value, (int, float)) — note that Union/| are not valid as the second argument to isinstance; use a tuple instead. X | Y unions in types.UnionType form (Python 3.10+) do work with isinstance(x, int | float), but typing.Union[...] never does.
- Prefer built-in generic aliases (
list,dict,tuple,set,type) overtypingequivalents when the minimum Python version allows it — they're simpler and require no import. - Prefer
X | YoverOptional[X]/Union[X, Y]on Python 3.10+. - Use
from __future__ import annotationsto unlock modern syntax in annotations on older interpreters (3.7+), remembering this only affects annotations, not runtime type objects. - For generic classes you define, subscript with built-ins too, e.g.
class Stack(list[T]): .... - Use
X | Yforisinstance/issubclasschecks only when targeting 3.10+; otherwise use a tuple(X, Y). - Keep annotation style consistent within a project; document the chosen minimum version and style in contribution guidelines.
- Using
list[int]orX | Yat runtime (not just in annotations) on Python < 3.9/3.10 — causesTypeError. - Mixing legacy and modern styles inconsistently within the same module.
- Passing
typing.Union[...]toisinstance/issubclass— it's not supported; use a tuple or a 3.10+X | Yunion instead. - Forgetting that
from __future__ import annotationsmakes all annotations strings (not evaluated), which can break tools that need real runtime type objects (e.g., some uses oftyping.get_type_hints, dataclasses relying on runtime introspection without resolution). - Assuming built-in generics support all
typingfeatures — some constructs (Callable,TypeVarbounds,Protocol,Literal,TypedDict) still require importing fromtyping.