Python — Type Hints & Generics
Type hints (PEP 484) allow you to annotate Python code with expected types. They are not enforced at runtime — Python remains dynamically typed — but static type checkers like mypy and pyright catch bugs before they reach production. Over successive Python releases, the type system has grown from basic annotations to a full-fledged generic type system with support for structural subtyping, literal types, and self-referential types.
This guide covers every major feature of Python's type system, from fundamental annotations to the latest syntactic additions in Python 3.12 and beyond.
note
The simplest annotations use Python's built-in types directly. Every function parameter and return value can be annotated with a colon-separated syntax.
| 1 | # Basic type annotations |
| 2 | def greet(name: str) -> str: |
| 3 | return f"Hello, {name}" |
| 4 | |
| 5 | def add(a: int, b: int) -> int: |
| 6 | return a + b |
| 7 | |
| 8 | def div(x: float, y: float) -> float: |
| 9 | return x / y |
| 10 | |
| 11 | def is_active(user: bool) -> bool: |
| 12 | return user is not None |
| 13 | |
| 14 | def no_return() -> None: |
| 15 | print("This function returns None") |
| 16 | |
| 17 | # Variables (Python 3.6+) |
| 18 | count: int = 0 |
| 19 | name: str = "Alice" |
| 20 | pi: float = 3.14159 |
| 21 | active: bool = True |
| 22 | result: None = None |
| 23 | |
| 24 | # Python ignores annotations at runtime — this still works: |
| 25 | count = "not a number" # mypy would warn, runtime runs fine |
| 1 | # Multiple annotations (PEP 484 — deprecated in favor of tuple) |
| 2 | # Parameter with default value |
| 3 | def multiply(a: int, b: int = 2) -> int: |
| 4 | return a * b |
| 5 | |
| 6 | # Positional-only and keyword-only (PEP 570/3102) |
| 7 | def func(a: int, /, b: str, *, c: bool) -> None: |
| 8 | pass # a is positional-only, c is keyword-only |
| 9 | |
| 10 | # Docstrings and annotations work together |
| 11 | def fib(n: int) -> list[int]: |
| 12 | """Return the first n Fibonacci numbers.""" |
| 13 | result = [0, 1] |
| 14 | for _ in range(n - 2): |
| 15 | result.append(result[-1] + result[-2]) |
| 16 | return result |
info
Container types like lists, dicts, and tuples need to specify their element types. Python 3.9+ allows using built-in generics directly; earlier versions require importing from typing.
| Container | 3.8- Style (typing) | 3.9+ Style |
|---|---|---|
| List | List[int] | list[int] |
| Dict | Dict[str, int] | dict[str, int] |
| Tuple | Tuple[int, ...] | tuple[int, ...] |
| Set | Set[str] | set[str] |
| Frozenset | Frozenset[int] | frozenset[int] |
| 1 | # Python 3.9+ — preferred (built-in generics) |
| 2 | from collections.abc import Sequence, Mapping |
| 3 | |
| 4 | def process(items: list[int]) -> None: |
| 5 | for item in items: |
| 6 | print(item) |
| 7 | |
| 8 | def lookup(table: dict[str, int], key: str) -> int | None: |
| 9 | return table.get(key) |
| 10 | |
| 11 | def unpack(data: tuple[str, int, bool]) -> None: |
| 12 | name, age, active = data |
| 13 | print(f"{name} is {age}, active={active}") |
| 14 | |
| 15 | # Variable-length tuple |
| 16 | def coords() -> tuple[float, ...]: |
| 17 | return (1.0, 2.0, 3.0, 4.0) |
| 18 | |
| 19 | # Use abstract types for function parameters |
| 20 | from collections.abc import Sequence, Mapping |
| 21 | |
| 22 | def total(values: Sequence[float]) -> float: |
| 23 | return sum(values) # accepts list, tuple, range, etc. |
| 24 | |
| 25 | def show(config: Mapping[str, str]) -> None: |
| 26 | for k, v in config.items(): |
| 27 | print(f"{k}: {v}") |
| 28 | |
| 29 | # Python 3.8- compatibility |
| 30 | from typing import List, Dict, Tuple, Set, Optional |
| 31 | |
| 32 | old_style: List[int] = [1, 2, 3] |
| 33 | old_dict: Dict[str, int] = {"a": 1} |
best practice
Values that can be None are expressed with Optional (Python 3.5+) or the union syntax X | None (Python 3.10+). Union types express "one of multiple types."
| 1 | # Optional — equivalent to Union[X, None] |
| 2 | from typing import Optional |
| 3 | |
| 4 | def find_user(uid: int) -> Optional[str]: |
| 5 | db = {1: "Alice", 2: "Bob"} |
| 6 | return db.get(uid) # returns str or None |
| 7 | |
| 8 | # Python 3.10+ — use | syntax (cleaner) |
| 9 | def find_user_v2(uid: int) -> str | None: |
| 10 | return {1: "Alice", 2: "Bob"}.get(uid) |
| 11 | |
| 12 | # Union — one of several types |
| 13 | from typing import Union |
| 14 | |
| 15 | def parse_id(value: Union[int, str]) -> int: |
| 16 | if isinstance(value, str): |
| 17 | return int(value) |
| 18 | return value |
| 19 | |
| 20 | # Python 3.10+ — shorter union syntax |
| 21 | def parse_id_v2(value: int | str) -> int: |
| 22 | return int(value) if isinstance(value, str) else value |
| 23 | |
| 24 | # Multiple unions |
| 25 | def handle(value: int | str | list[str] | None) -> None: |
| 26 | match value: |
| 27 | case int(n): |
| 28 | print(f"int: {n}") |
| 29 | case str(s): |
| 30 | print(f"str: {s}") |
| 31 | case list(items): |
| 32 | print(f"list: {items}") |
| 33 | case None: |
| 34 | print("nothing") |
| 35 | |
| 36 | # Narrowing with isinstance |
| 37 | def process(value: int | str) -> int: |
| 38 | if isinstance(value, int): |
| 39 | return value * 2 # type-checker knows: int |
| 40 | return len(value) # type-checker knows: str |
info
Generics allow functions, classes, and type aliases to work flexibly across types while preserving type relationships. TypeVar is the foundational building block; newer Python versions add syntactic sugar.
TypeVar — Type Variables
| 1 | from typing import TypeVar |
| 2 | |
| 3 | # Single type variable |
| 4 | T = TypeVar("T") |
| 5 | |
| 6 | def first(items: list[T]) -> T: |
| 7 | return items[0] |
| 8 | |
| 9 | # Type is preserved: |
| 10 | revealed = first([1, 2, 3]) # inferred: int |
| 11 | revealed = first(["a", "b"]) # inferred: str |
| 12 | |
| 13 | # Bounded TypeVar — restricts to str or subclasses |
| 14 | StrType = TypeVar("StrType", bound=str) |
| 15 | |
| 16 | def concat(a: StrType, b: StrType) -> StrType: |
| 17 | return a + b # type-checker knows it's a string |
| 18 | |
| 19 | # Constrained TypeVar — only specific types |
| 20 | NumType = TypeVar("NumType", int, float) |
| 21 | |
| 22 | def double(x: NumType) -> NumType: |
| 23 | return x * 2 |
| 24 | |
| 25 | double(5) # ok |
| 26 | double(3.0) # ok |
| 27 | double("a") # type error |
| 28 | |
| 29 | # Multiple TypeVars |
| 30 | K = TypeVar("K") |
| 31 | V = TypeVar("V") |
| 32 | |
| 33 | def get_or_default(d: dict[K, V], key: K, default: V) -> V: |
| 34 | return d.get(key, default) |
Generic Classes
| 1 | from typing import Generic, TypeVar |
| 2 | |
| 3 | T = TypeVar("T") |
| 4 | |
| 5 | # Generic class — akin to templates in other languages |
| 6 | class Stack(Generic[T]): |
| 7 | def __init__(self) -> None: |
| 8 | self._items: list[T] = [] |
| 9 | |
| 10 | def push(self, item: T) -> None: |
| 11 | self._items.append(item) |
| 12 | |
| 13 | def pop(self) -> T: |
| 14 | return self._items.pop() |
| 15 | |
| 16 | def peek(self) -> T: |
| 17 | return self._items[-1] |
| 18 | |
| 19 | @property |
| 20 | def empty(self) -> bool: |
| 21 | return len(self._items) == 0 |
| 22 | |
| 23 | # Usage — type is inferred |
| 24 | int_stack = Stack[int]() |
| 25 | int_stack.push(1) |
| 26 | int_stack.push(2) |
| 27 | val = int_stack.pop() # inferred: int |
| 28 | |
| 29 | str_stack = Stack[str]() |
| 30 | str_stack.push("hello") |
| 31 | val2 = str_stack.pop() # inferred: str |
| 32 | |
| 33 | # Multiple type parameters |
| 34 | class Pair(Generic[K, V]): |
| 35 | def __init__(self, key: K, value: V) -> None: |
| 36 | self.key = key |
| 37 | self.value = value |
| 38 | |
| 39 | def to_tuple(self) -> tuple[K, V]: |
| 40 | return (self.key, self.value) |
TypeAlias — Named Type Aliases (3.10+)
| 1 | from typing import TypeAlias |
| 2 | |
| 3 | # Python 3.10+ — explicit type alias |
| 4 | Vector: TypeAlias = list[float] |
| 5 | Matrix: TypeAlias = list[Vector] |
| 6 | JSON: TypeAlias = str | int | float | bool | None | dict[str, "JSON"] | list["JSON"] |
| 7 | |
| 8 | def scale(v: Vector, factor: float) -> Vector: |
| 9 | return [x * factor for x in v] |
| 10 | |
| 11 | def transpose(m: Matrix) -> Matrix: |
| 12 | return list(map(list, zip(*m))) |
| 13 | |
| 14 | # Python 3.12+ — type statement (no import needed) |
| 15 | type Vector2 = list[float] |
| 16 | |
| 17 | type Point3D = tuple[float, float, float] |
| 18 | |
| 19 | def distance(p: Point3D, q: Point3D) -> float: |
| 20 | return sum((a - b) ** 2 for a, b in zip(p, q)) ** 0.5 |
| 21 | |
| 22 | # Self-referential alias (for recursive types) |
| 23 | from typing import TypeAlias |
| 24 | |
| 25 | JSONValue: TypeAlias = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"] |
Concise Generics with [T] Syntax (3.12+)
| 1 | # Python 3.12+ — PEP 695: concise generic syntax |
| 2 | # No TypeVar import needed! |
| 3 | |
| 4 | def first[T](items: list[T]) -> T: # instead of TypeVar + def |
| 5 | return items[0] |
| 6 | |
| 7 | class Stack[T]: # instead of Generic[T] |
| 8 | def __init__(self) -> None: |
| 9 | self._items: list[T] = [] |
| 10 | |
| 11 | def push(self, item: T) -> None: |
| 12 | self._items.append(item) |
| 13 | |
| 14 | def pop(self) -> T: |
| 15 | return self._items.pop() |
| 16 | |
| 17 | # Multiple type parameters |
| 18 | def swap[K, V](key: K, value: V) -> tuple[V, K]: |
| 19 | return (value, key) |
| 20 | |
| 21 | class Pair[K, V]: |
| 22 | def __init__(self, key: K, value: V) -> None: |
| 23 | self.key = key |
| 24 | self.value = value |
| 25 | |
| 26 | # Bounds work too |
| 27 | def first_str[T: str](items: list[T]) -> T: |
| 28 | return items[0] |
| 29 | |
| 30 | # Type alias with type parameters |
| 31 | type Tree[T] = T | list["Tree[T]"] # recursive type alias |
| 32 | |
| 33 | # This is the modern, idiomatic way since 3.12. |
Callable types describe the signature of functions, lambdas, and other callable objects. It takes a list of argument types and a return type.
| 1 | from collections.abc import Callable |
| 2 | |
| 3 | # Callable[[ArgType1, ArgType2, ...], ReturnType] |
| 4 | |
| 5 | # Function that takes two ints and returns an int |
| 6 | def apply(fn: Callable[[int, int], int], a: int, b: int) -> int: |
| 7 | return fn(a, b) |
| 8 | |
| 9 | result = apply(lambda x, y: x + y, 3, 4) # 7 |
| 10 | result = apply(lambda x, y: x * y, 3, 4) # 12 |
| 11 | |
| 12 | # Callable with no arguments |
| 13 | def run(fn: Callable[[], str]) -> None: |
| 14 | print(fn()) |
| 15 | |
| 16 | # Callable with variable arguments |
| 17 | Callback = Callable[..., None] # any args, returns None |
| 18 | |
| 19 | def register(handler: Callback) -> None: |
| 20 | handler("any", "args", 42) # ok |
| 21 | |
| 22 | # Protocol for more complex signatures |
| 23 | from typing import Protocol |
| 24 | |
| 25 | class IntOperator(Protocol): |
| 26 | def __call__(self, a: int, b: int) -> int: ... |
| 27 | |
| 28 | def compute(op: IntOperator, x: int, y: int) -> int: |
| 29 | return op(x, y) |
| 30 | |
| 31 | compute(lambda a, b: a + b, 1, 2) # ok — structurally matches |
| 32 | |
| 33 | # TypeVar for generic callbacks |
| 34 | T = TypeVar("T") |
| 35 | |
| 36 | def map_items(items: list[T], fn: Callable[[T], T]) -> list[T]: |
| 37 | return [fn(x) for x in items] |
info
TypedDict (PEP 589) lets you define the exact keys and value types for a dictionary. Unlike dataclasses, TypedDict objects are plain dicts at runtime — useful for JSON APIs and configuration data.
| 1 | from typing import TypedDict |
| 2 | |
| 3 | # Class-based syntax (Python 3.8+) |
| 4 | class User(TypedDict): |
| 5 | name: str |
| 6 | age: int |
| 7 | email: str |
| 8 | |
| 9 | def create_user(name: str, age: int) -> User: |
| 10 | return {"name": name, "age": age, "email": f"{name}@example.com"} |
| 11 | |
| 12 | user = create_user("Alice", 30) |
| 13 | print(user["name"]) # ok |
| 14 | print(user["age"]) # ok |
| 15 | print(user["unknown"]) # type error — key doesn't exist |
| 16 | |
| 17 | # Dict-literal syntax (Python 3.11+) |
| 18 | from typing import TypedDict, Unpack |
| 19 | |
| 20 | Movie = TypedDict("Movie", {"title": str, "year": int}) |
| 21 | |
| 22 | # Optional keys — use total=False |
| 23 | class Config(TypedDict, total=False): |
| 24 | debug: bool |
| 25 | timeout: float |
| 26 | host: str |
| 27 | |
| 28 | # All keys are optional: |
| 29 | cfg: Config = {} # ok |
| 30 | cfg2: Config = {"debug": True, "host": "localhost"} # ok |
| 31 | |
| 32 | # Mix required and optional (Python 3.11+) |
| 33 | class Request(TypedDict): |
| 34 | method: str |
| 35 | path: str |
| 36 | body: str | None |
| 37 | headers: dict[str, str] |
| 38 | query_params: dict[str, str] # not required in total=False |
| 39 | |
| 40 | class PartialRequest(Request, total=False): |
| 41 | timeout: float |
| 42 | retry: bool |
| 43 | |
| 44 | # Unpack — spread TypedDict into function kwargs (3.11+) |
| 45 | from typing import Unpack |
| 46 | |
| 47 | def register_user(**kwargs: Unpack[User]) -> None: |
| 48 | print(f"Registering {kwargs['name']}") |
| 49 | |
| 50 | register_user(name="Bob", age=25, email="bob@example.com") |
best practice
A Protocol (PEP 544) defines an interface by the methods and attributes it requires, not by explicit inheritance. This is structural subtyping — "duck typing" with static checking. Any object with the right shape satisfies the protocol automatically.
| 1 | from typing import Protocol |
| 2 | |
| 3 | # Define a protocol — what methods must exist |
| 4 | class Drawable(Protocol): |
| 5 | def draw(self) -> None: ... |
| 6 | |
| 7 | # These classes satisfy Drawable without explicit inheritance |
| 8 | class Circle: |
| 9 | def draw(self) -> None: |
| 10 | print("Drawing circle") |
| 11 | |
| 12 | class Square: |
| 13 | def draw(self) -> None: |
| 14 | print("Drawing square") |
| 15 | |
| 16 | class Triangle: |
| 17 | def draw(self) -> None: |
| 18 | print("Drawing triangle") |
| 19 | |
| 20 | # Function accepts anything that satisfies Drawable |
| 21 | def render(shapes: list[Drawable]) -> None: |
| 22 | for shape in shapes: |
| 23 | shape.draw() # type-checker is satisfied |
| 24 | |
| 25 | render([Circle(), Square(), Triangle()]) # all ok |
| 26 | |
| 27 | # Protocols with attributes |
| 28 | class HasName(Protocol): |
| 29 | name: str |
| 30 | |
| 31 | def greet(obj: HasName) -> str: |
| 32 | return f"Hello, {obj.name}" |
| 33 | |
| 34 | class Person: |
| 35 | def __init__(self, name: str) -> None: |
| 36 | self.name = name |
| 37 | |
| 38 | class Pet: |
| 39 | def __init__(self, name: str) -> None: |
| 40 | self.name = name |
| 41 | |
| 42 | greet(Person("Alice")) # ok |
| 43 | greet(Pet("Rex")) # ok — both have .name |
| 44 | |
| 45 | # Runtime checkable (limited) |
| 46 | from typing import runtime_checkable |
| 47 | |
| 48 | @runtime_checkable |
| 49 | class SupportsClose(Protocol): |
| 50 | def close(self) -> None: ... |
| 51 | |
| 52 | print(isinstance(open("/dev/null"), SupportsClose)) # True |
| 1 | # Iterator protocol example |
| 2 | from typing import Protocol, TypeVar |
| 3 | |
| 4 | T = TypeVar("T", covariant=True) |
| 5 | |
| 6 | class Iterable(Protocol[T]): |
| 7 | def __iter__(self) -> Iterator[T]: ... |
| 8 | |
| 9 | class Iterator(Iterable[T], Protocol[T]): |
| 10 | def __next__(self) -> T: ... |
| 11 | def __iter__(self) -> "Iterator[T]": ... |
| 12 | |
| 13 | # Generic protocol — reusable across types |
| 14 | class Comparable(Protocol): |
| 15 | def __lt__(self, other: object) -> bool: ... |
| 16 | |
| 17 | def max_item[T: Comparable](items: list[T]) -> T: |
| 18 | return max(items) |
| 19 | |
| 20 | # Protocol composition — use inheritance |
| 21 | class Readable(Protocol): |
| 22 | def read(self) -> str: ... |
| 23 | |
| 24 | class Writable(Protocol): |
| 25 | def write(self, data: str) -> None: ... |
| 26 | |
| 27 | class ReadWrite(Readable, Writable, Protocol): |
| 28 | """Combines both protocols.""" |
| 29 | ... |
| 30 | |
| 31 | # Use with TypeGuard for runtime narrowing |
| 32 | from typing import TypeGuard |
| 33 | |
| 34 | def is_drawable(obj: object) -> TypeGuard[Drawable]: |
| 35 | return hasattr(obj, "draw") |
best practice
@overload lets you declare multiple type signatures for a single function. The overloads are type-checker-only declarations; the actual implementation must handle all cases.
| 1 | from typing import overload |
| 2 | |
| 3 | # Overloads — each is a type signature |
| 4 | @overload |
| 5 | def process(item: int) -> int: ... |
| 6 | |
| 7 | @overload |
| 8 | def process(item: str) -> list[str]: ... |
| 9 | |
| 10 | @overload |
| 11 | def process(item: list[str]) -> dict[str, int]: ... |
| 12 | |
| 13 | # Implementation — handles all overload cases |
| 14 | def process(item: int | str | list[str]) -> int | list[str] | dict[str, int]: |
| 15 | if isinstance(item, int): |
| 16 | return item * 2 |
| 17 | elif isinstance(item, str): |
| 18 | return [c for c in item] |
| 19 | else: |
| 20 | return {s: len(s) for s in item} |
| 21 | |
| 22 | # Usage — type-checker picks the right overload |
| 23 | result1 = process(42) # inferred: int |
| 24 | result2 = process("hello") # inferred: list[str] |
| 25 | result3 = process(["a", "bc"]) # inferred: dict[str, int] |
| 26 | |
| 27 | # Common pattern: dispatching on type |
| 28 | @overload |
| 29 | def find(key: int) -> str: ... |
| 30 | |
| 31 | @overload |
| 32 | def find(key: str) -> int: ... |
| 33 | |
| 34 | def find(key: int | str) -> str | int: |
| 35 | if isinstance(key, int): |
| 36 | return f"user_{key}" |
| 37 | return hash(key) |
| 38 | |
| 39 | # Overloads with Literal for precise types |
| 40 | from typing import Literal |
| 41 | |
| 42 | @overload |
| 43 | def set_mode(mode: Literal["read"]) -> str: ... |
| 44 | |
| 45 | @overload |
| 46 | def set_mode(mode: Literal["write"]) -> int: ... |
| 47 | |
| 48 | @overload |
| 49 | def set_mode(mode: Literal["append"]) -> float: ... |
| 50 | |
| 51 | def set_mode(mode: str) -> str | int | float: |
| 52 | match mode: |
| 53 | case "read": return "r" |
| 54 | case "write": return 1 |
| 55 | case "append": return 1.5 |
| 56 | case _: return "unknown" |
info
Python's type system includes several advanced features that handle edge cases and enable more precise typing.
Literal Types (PEP 586)
| 1 | from typing import Literal |
| 2 | |
| 3 | # Constrain a value to specific literals |
| 4 | def set_status(status: Literal["active", "inactive", "pending"]) -> None: |
| 5 | print(f"Status set to {status}") |
| 6 | |
| 7 | set_status("active") # ok |
| 8 | set_status("deleted") # type error |
| 9 | |
| 10 | # Literal with int/bool |
| 11 | def open_file(mode: Literal["r", "w", "a", "rb", "wb"]) -> str: |
| 12 | return f"opened with {mode}" |
| 13 | |
| 14 | def set_timeout(seconds: Literal[30, 60, 120, 300]) -> None: |
| 15 | pass |
| 16 | |
| 17 | # Combining Literal with Union |
| 18 | Action = Literal["create", "read", "update", "delete"] | str |
| 19 | |
| 20 | # Literal in overloads for precise return types (see @overload section) |
Final — Constants (PEP 591)
| 1 | from typing import Final |
| 2 | |
| 3 | # Variable that should never be reassigned |
| 4 | MAX_RETRIES: Final = 3 |
| 5 | PI: Final[float] = 3.14159 |
| 6 | |
| 7 | # Reassignment is a type error |
| 8 | MAX_RETRIES = 5 # mypy error: cannot assign to final name |
| 9 | |
| 10 | # Final methods — subclasses cannot override |
| 11 | from typing import final |
| 12 | |
| 13 | class Base: |
| 14 | @final |
| 15 | def close(self) -> None: |
| 16 | print("closing") |
| 17 | |
| 18 | class Derived(Base): |
| 19 | def close(self) -> None: # type error |
| 20 | print("override") |
| 21 | |
| 22 | # Final classes — cannot be subclassed |
| 23 | @final |
| 24 | class ImmutablePoint: |
| 25 | def __init__(self, x: float, y: float) -> None: |
| 26 | self.x = x |
| 27 | self.y = y |
| 28 | |
| 29 | class ExtendedPoint(ImmutablePoint): # type error |
| 30 | pass |
Self — Return Self Type (3.11+)
| 1 | from typing import Self |
| 2 | |
| 3 | # Self type — return the actual subclass type |
| 4 | class Shape: |
| 5 | def set_color(self, color: str) -> Self: |
| 6 | self.color = color |
| 7 | return self |
| 8 | |
| 9 | class Circle(Shape): |
| 10 | def set_radius(self, r: float) -> Self: |
| 11 | self.radius = r |
| 12 | return self |
| 13 | |
| 14 | # Method chaining works with subtypes |
| 15 | circle = Circle() |
| 16 | circle.set_color("red").set_radius(5.0) # ok — returns Circle, not Shape |
| 17 | |
| 18 | # Self in classmethods |
| 19 | class Config: |
| 20 | @classmethod |
| 21 | def from_env(cls) -> Self: |
| 22 | return cls(host=os.getenv("HOST"), port=int(os.getenv("PORT"))) |
| 23 | |
| 24 | def __init__(self, host: str, port: int) -> None: |
| 25 | self.host = host |
| 26 | self.port = port |
| 27 | |
| 28 | # Before 3.11 — use TypeVar (verbose) |
| 29 | T = TypeVar("T", bound="Shape") |
| 30 | |
| 31 | class Shape: |
| 32 | def set_color(self: T, color: str) -> T: |
| 33 | self.color = color |
| 34 | return self |
| 35 | |
| 36 | # Self is cleaner — use it for 3.11+ codebases |
NewType — Distinct Types
| 1 | from typing import NewType |
| 2 | |
| 3 | # Create a distinct type (zero runtime overhead) |
| 4 | UserId = NewType("UserId", int) |
| 5 | ProductId = NewType("ProductId", int) |
| 6 | |
| 7 | def lookup_user(uid: UserId) -> str: |
| 8 | return f"user_{uid}" |
| 9 | |
| 10 | def lookup_product(pid: ProductId) -> str: |
| 11 | return f"product_{pid}" |
| 12 | |
| 13 | # These are incompatible at type-checking time |
| 14 | user = UserId(42) |
| 15 | product = ProductId(99) |
| 16 | |
| 17 | lookup_user(user) # ok |
| 18 | lookup_user(product) # type error — ProductId != UserId |
| 19 | |
| 20 | # NewType wraps the original — operations still work |
| 21 | val: int = user + 1 # ok — UserId is-a int for operations |
| 22 | |
| 23 | # But you can pass int where NewType is expected |
| 24 | lookup_user(42) # type error — int != UserId |
| 25 | |
| 26 | # NewType at runtime is just the identity function |
| 27 | print(type(UserId(42))) # <class 'int'> — no wrapper at runtime |
Any — The Escape Hatch
| 1 | from typing import Any |
| 2 | |
| 3 | # Any opts out of type checking entirely |
| 4 | def process(data: Any) -> Any: |
| 5 | return data.does_not_exist() # no error |
| 6 | |
| 7 | # Any is infectious — avoid unless necessary |
| 8 | def bad_api(value: Any) -> None: |
| 9 | # Type-checker trusts anything you do with 'value' |
| 10 | result = value + "hello" # might fail at runtime |
| 11 | |
| 12 | # Gradual typing: start with Any, narrow over time |
| 13 | def migrate(initial: Any) -> str: |
| 14 | # During migration, accept anything |
| 15 | return str(initial) |
| 16 | |
| 17 | # typed_ast — cast when you know better |
| 18 | from typing import cast |
| 19 | |
| 20 | def double(value: object) -> int: |
| 21 | # You know it's an int, the type-checker doesn't |
| 22 | return cast(int, value) * 2 |
| 23 | |
| 24 | # type: ignore — suppress errors inline |
| 25 | x: int = "hello" # type: ignore # explicitly opting out |
warning
Two primary type checkers dominate the Python ecosystem: mypy (the reference implementation) and pyright (by Microsoft, used by VS Code's Pylance). Both support the full type hint specification.
| 1 | # mypy configuration — mypy.ini or pyproject.toml |
| 2 | |
| 3 | # mypy.ini |
| 4 | [mypy] |
| 5 | python_version = 3.12 |
| 6 | strict = True # enable all optional checks |
| 7 | ignore_missing_imports = True # skip libraries without stubs |
| 8 | disallow_untyped_defs = True # require annotations on all functions |
| 9 | disallow_any_unimported = False |
| 10 | no_implicit_optional = True # require explicit Optional |
| 11 | warn_redundant_casts = True |
| 12 | warn_unused_ignores = True |
| 13 | warn_return_any = True |
| 14 | strict_equality = True # warn on == between incompatible types |
| 15 | |
| 16 | # Per-module overrides |
| 17 | [mypy-plugins.*] |
| 18 | ignore_errors = True |
| 19 | |
| 20 | # pyproject.toml equivalent (PEP 621) |
| 21 | [tool.mypy] |
| 22 | python_version = "3.12" |
| 23 | strict = true |
| 24 | ignore_missing_imports = true |
| 25 | |
| 26 | # pyright configuration — pyproject.toml |
| 27 | [tool.pyright] |
| 28 | typeCheckingMode = "strict" # "basic" | "standard" | "strict" |
| 29 | include = ["src"] |
| 30 | exclude = ["tests", "venv"] |
| 31 | pythonVersion = "3.12" |
| 32 | reportMissingTypeStubs = false |
| 33 | reportMissingParameterType = false |
| 34 | |
| 35 | # Running mypy |
| 36 | $ mypy src/ |
| 37 | $ mypy src/ --strict |
| 38 | $ mypy src/models.py src/services/ |
| 39 | |
| 40 | # Running pyright |
| 41 | $ pyright src/ |
| 42 | $ pyright --warnings src/ # treat warnings as errors |
| 1 | # Gradual typing — start somewhere |
| 2 | # Step 1: annotate public functions |
| 3 | def public_api(x: int, y: str) -> bool: |
| 4 | ... |
| 5 | |
| 6 | # Step 2: add pyproject.toml config with loose settings |
| 7 | # Step 3: enable strict mode module by module |
| 8 | # Step 4: fix all errors, then enable globally |
| 9 | |
| 10 | # Type stubs (*.pyi files) for third-party libraries |
| 11 | # Or use typeshed: pip install types-requests types-PyYAML |
| 12 | |
| 13 | # inline: # type: ignore[arg-type] |
| 14 | result = some_third_party_func() # type: ignore[return-value] |
| 15 | |
| 16 | # Check environment — see what version of typing features are available |
| 17 | import sys |
| 18 | print(sys.version_info >= (3, 10)) # union syntax: int | str |
| 19 | print(sys.version_info >= (3, 11)) # Self, Unpack |
| 20 | print(sys.version_info >= (3, 12)) # type statement, [T] syntax |
| 21 | |
| 22 | # Running mypy in CI |
| 23 | # .github/workflows/typecheck.yml |
| 24 | # - run: pip install mypy types-requests |
| 25 | # - run: mypy src/ |
info
Effective type hinting is a skill that balances correctness with readability. These guidelines help you write maintainable, well-typed Python code.
| Guideline | Why |
|---|---|
| Prefer | over Union/Optional | Cleaner since 3.10 |
| Use Sequence over list | Accepts tuples, ranges, other sequences |
| Protocol over ABC | Structural typing is more flexible |
| Strict mypy config | Catches the most errors |
| Avoid Any | Defeats the purpose of type checking |
| Use NewType for IDs | Prevents passing wrong ID types |
| Self over TypeVar for methods | Cleaner and correct for chaining |
| TypedDict for JSON shapes | Runtime plain dict, statically checked |
| Concise [T] syntax on 3.12+ | Less boilerplate, modern style |
| Run mypy in CI | Catches regressions automatically |
| 1 | # Good: specific, narrow types |
| 2 | def fetch_user(db: Database, uid: UserId) -> User | None: |
| 3 | ... |
| 4 | |
| 5 | # Bad: overuse of Any |
| 6 | def fetch_user(db: Any, uid: Any) -> Any: |
| 7 | ... |
| 8 | |
| 9 | # Good: use Protocol for interfaces |
| 10 | class Logger(Protocol): |
| 11 | def log(self, message: str) -> None: ... |
| 12 | |
| 13 | # Bad: forcing inheritance |
| 14 | class Logger(ABC): |
| 15 | @abstractmethod |
| 16 | def log(self, message: str) -> None: ... |
| 17 | |
| 18 | # Good: TypedDict for API responses |
| 19 | class APIResponse(TypedDict): |
| 20 | status: Literal["ok", "error"] |
| 21 | data: dict[str, Any] |
| 22 | error: str | None |
| 23 | |
| 24 | # Good: type alias for complex types |
| 25 | JSONDict: TypeAlias = dict[str, "JSONValue"] |
| 26 | |
| 27 | # Good: use Final for configuration constants |
| 28 | DEFAULT_TIMEOUT: Final[float] = 30.0 |
| 29 | |
| 30 | # Good: overload for dispatching on input type |
| 31 | @overload |
| 32 | def load(path: str) -> dict[str, Any]: ... |
| 33 | @overload |
| 34 | def load(path: int) -> str: ... |
| 35 | |
| 36 | # Remember: type hints are a tool, not a religion |
| 37 | # If a type is too complex, consider whether the |
| 38 | # function itself is too complex. |
best practice