List, Dict, Tuple: the imports you no longer need
Before 3.9, annotating a function that takes a list of integers required importing List from typing because list[int] was a syntax error. The built-in list type did not support subscripting at runtime.
1# 3.5-3.8: you had to import capitalized wrappers2from typing import Dict, FrozenSet, List, Optional, Set, Tuple34def process(items: List[int]) -> Dict[str, List[int]]:5 ...67def coords() -> Tuple[float, float, float]:8 ...910def unique(values: List[str]) -> Set[str]:11 ...
Python 3.9 made built-in collection types subscriptable directly. The typing versions still work, but they are deprecated for this purpose and will eventually be removed. Delete the import, lowercase everything.
1# 3.9+: no import needed, use the built-ins directly2def process(items: list[int]) -> dict[str, list[int]]:3 ...45def coords() -> tuple[float, float, float]:6 ...78def unique(values: list[str]) -> set[str]:9 ...
The full list of types that gained subscript support in 3.9: list, dict, tuple, set, frozenset, type, plus stdlib types in collections, collections.abc, and others.
1# stdlib types also work directly now2from collections import defaultdict, deque3from collections.abc import Callable, Iterator, Generator45def pipeline(steps: list[Callable[[int], int]]) -> Callable[[int], int]:6 def run(value: int) -> int:7 for step in steps:8 value = step(value)9 return value10 return run1112def count_up(start: int) -> Iterator[int]:13 while True:14 yield start15 start += 1
Optional[str] is str | None. Write it that way.
Optional[str] is not a distinct concept. It is an alias for Union[str, None]. The name "Optional" is actively misleading: it implies the parameter is optional (has a default), when all it means is the value can be None. Python 3.10 gave us the syntax to say that plainly.
1# 3.5-3.9: two separate imports, verbose nesting2from typing import Optional, Union34def find_user(user_id: int) -> Optional[str]:5 ...67def parse(value: Union[str, int, bytes]) -> str:8 ...910def fetch(url: str, timeout: Optional[float] = None) -> Optional[bytes]:11 ...
1# 3.10+: | operator, no imports, reads like English2def find_user(user_id: int) -> str | None:3 ...45def parse(value: str | int | bytes) -> str:6 ...78def fetch(url: str, timeout: float | None = None) -> bytes | None:9 ...
The | syntax works at runtime in 3.10+, meaning you can use it in isinstance() checks too:
1# 3.10+: isinstance() with union types2def process(value: str | int) -> str:3 if isinstance(value, str | int): # works at runtime4 return str(value)5 raise TypeError(value)67# isinstance still also accepts tuples, which is the pre-3.10 way8isinstance(value, (str, int)) # old9isinstance(value, str | int) # new
Using 3.10 syntax on older runtimes
If your code runs on 3.8 or 3.9 but you want the cleaner syntax in annotations, add this import at the top of the file. It makes all annotations lazy strings instead of evaluated expressions, so the runtime never sees the | syntax and does not choke.
1from __future__ import annotations # top of file23# Now you can write 3.10-style hints on 3.8+4def find_user(user_id: int) -> str | None:5 ...67# Caveat: annotations are now strings, not evaluated.8# inspect.get_annotations() or typing.get_type_hints() evaluates them.9# Direct access via __annotations__ gives the raw string.
Type aliases: from assignment to first-class syntax
The old way to create a type alias was a plain assignment. It worked, but nothing distinguished it from a regular variable assignment. Type checkers had to guess from context whether you meant a value or a type.
1# 3.5-3.9: just an assignment, no signal that this is a type alias2from typing import List, Tuple34Coordinate = Tuple[float, float]5Matrix = List[List[float]]6UserId = int # is this a new type or just an alias? ambiguous.78def translate(point: Coordinate, delta: Coordinate) -> Coordinate:9 return (point[0] + delta[0], point[1] + delta[1])
Python 3.10 introduced TypeAlias to make intent explicit. The annotation tells both the type checker and any human reading the code: this name is a type alias, not a value.
1# 3.10: TypeAlias makes intent unambiguous2from typing import TypeAlias34Coordinate: TypeAlias = tuple[float, float]5Matrix: TypeAlias = list[list[float]]6UserId: TypeAlias = int78def translate(point: Coordinate, delta: Coordinate) -> Coordinate:9 return (point[0] + delta[0], point[1] + delta[1])
3.12: the type statement
Python 3.12 went further and added a dedicated statement for type aliases. No import needed. The alias is lazy: the right-hand side is not evaluated until the alias is used, which means forward references and recursive types work without strings.
1# 3.12: type statement, no import, lazy evaluation2type Coordinate = tuple[float, float]3type Matrix = list[list[float]]4type JsonValue = str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]56# Recursive type works without quotes because evaluation is deferred7type Tree[T] = T | list[Tree[T]] # generic type alias, also new in 3.12
The progression: plain assignment (ambiguous) → annotated with TypeAlias (explicit) → type statement (first-class syntax). If you are on 3.12, use the type statement. On 3.10-3.11, use TypeAlias. On older, the plain assignment is still understood by every major type checker.
TYPE_CHECKING: imports that only exist for the type checker
Some imports exist purely for type annotations. At runtime, they are unnecessary and may even create circular import problems. The TYPE_CHECKING constant is False at runtime and True only when a static type checker is analysing the code. Wrap annotation-only imports in it.
1# 3.5-3.8: import everything at the top, including things only used in annotations2from myapp.models import User # heavy import, causes circular deps3from myapp.services import Report # another heavy import45def get_report(user: User) -> Report:6 ...
1# Modern: conditional import, zero runtime cost2from __future__ import annotations # makes annotations strings, defers evaluation3from typing import TYPE_CHECKING45if TYPE_CHECKING:6 from myapp.models import User # only imported during type checking7 from myapp.services import Report # not imported at runtime89def get_report(user: User) -> Report: # fine because annotations are strings10 ...
The from __future__ import annotations is required here: without it, Python would try to evaluate the annotation at function definition time and hit a NameError because User was never imported at runtime.
Putting the modern type hint style together
1# A modern Python 3.12 module header2from __future__ import annotations # only needed if supporting < 3.1034from collections.abc import Callable, Iterator5from typing import TYPE_CHECKING67if TYPE_CHECKING:8 from myapp.db import Connection910type UserId = int11type Callback[T] = Callable[[T], None]1213def find_users(14 db: Connection,15 *,16 active: bool = True,17 limit: int | None = None,18) -> Iterator[UserId]:19 ...
The rule: lowercase built-ins for generic types (3.9+), | for unions (3.10+), type for aliases (3.12+), TYPE_CHECKING for annotation-only imports. Pick the floor version your project targets and use everything above it.