Codetail

Article 1 of 8

Type Hints Done Right

From typing.List to list[int]. The full evolution.

20 min read

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.

Python
1# 3.5-3.8: you had to import capitalized wrappers
2from typing import Dict, FrozenSet, List, Optional, Set, Tuple
3
4def process(items: List[int]) -> Dict[str, List[int]]:
5 ...
6
7def coords() -> Tuple[float, float, float]:
8 ...
9
10def 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.

Python
1# 3.9+: no import needed, use the built-ins directly
2def process(items: list[int]) -> dict[str, list[int]]:
3 ...
4
5def coords() -> tuple[float, float, float]:
6 ...
7
8def 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.

Python
1# stdlib types also work directly now
2from collections import defaultdict, deque
3from collections.abc import Callable, Iterator, Generator
4
5def 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 value
10 return run
11
12def count_up(start: int) -> Iterator[int]:
13 while True:
14 yield start
15 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.

Python
1# 3.5-3.9: two separate imports, verbose nesting
2from typing import Optional, Union
3
4def find_user(user_id: int) -> Optional[str]:
5 ...
6
7def parse(value: Union[str, int, bytes]) -> str:
8 ...
9
10def fetch(url: str, timeout: Optional[float] = None) -> Optional[bytes]:
11 ...
Python
1# 3.10+: | operator, no imports, reads like English
2def find_user(user_id: int) -> str | None:
3 ...
4
5def parse(value: str | int | bytes) -> str:
6 ...
7
8def 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:

Python
1# 3.10+: isinstance() with union types
2def process(value: str | int) -> str:
3 if isinstance(value, str | int): # works at runtime
4 return str(value)
5 raise TypeError(value)
6
7# isinstance still also accepts tuples, which is the pre-3.10 way
8isinstance(value, (str, int)) # old
9isinstance(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.

Python
1from __future__ import annotations # top of file
2
3# Now you can write 3.10-style hints on 3.8+
4def find_user(user_id: int) -> str | None:
5 ...
6
7# 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.

Python
1# 3.5-3.9: just an assignment, no signal that this is a type alias
2from typing import List, Tuple
3
4Coordinate = Tuple[float, float]
5Matrix = List[List[float]]
6UserId = int # is this a new type or just an alias? ambiguous.
7
8def 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.

Python
1# 3.10: TypeAlias makes intent unambiguous
2from typing import TypeAlias
3
4Coordinate: TypeAlias = tuple[float, float]
5Matrix: TypeAlias = list[list[float]]
6UserId: TypeAlias = int
7
8def 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.

Python
1# 3.12: type statement, no import, lazy evaluation
2type Coordinate = tuple[float, float]
3type Matrix = list[list[float]]
4type JsonValue = str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]
5
6# Recursive type works without quotes because evaluation is deferred
7type 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.

Python
1# 3.5-3.8: import everything at the top, including things only used in annotations
2from myapp.models import User # heavy import, causes circular deps
3from myapp.services import Report # another heavy import
4
5def get_report(user: User) -> Report:
6 ...
Python
1# Modern: conditional import, zero runtime cost
2from __future__ import annotations # makes annotations strings, defers evaluation
3from typing import TYPE_CHECKING
4
5if TYPE_CHECKING:
6 from myapp.models import User # only imported during type checking
7 from myapp.services import Report # not imported at runtime
8
9def get_report(user: User) -> Report: # fine because annotations are strings
10 ...

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

Python
1# A modern Python 3.12 module header
2from __future__ import annotations # only needed if supporting < 3.10
3
4from collections.abc import Callable, Iterator
5from typing import TYPE_CHECKING
6
7if TYPE_CHECKING:
8 from myapp.db import Connection
9
10type UserId = int
11type Callback[T] = Callable[[T], None]
12
13def 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.