|$ curl https://forge-ai.dev/api/markdown?path=docs/python/type-hints
$cat docs/python-—-type-hints-&-generics.md
updated Recently·18 min read·published

Python — Type Hints & Generics

PythonAdvanced
Introduction

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

Type hints are optional. Python will never enforce types at runtime — the interpreter ignores annotations entirely. Tools like mypy, pyright, and IDE plugins are what make type hints useful.
Basic Type Annotations

The simplest annotations use Python's built-in types directly. Every function parameter and return value can be annotated with a colon-separated syntax.

basic_types.py
Python
1# Basic type annotations
2def greet(name: str) -> str:
3 return f"Hello, {name}"
4
5def add(a: int, b: int) -> int:
6 return a + b
7
8def div(x: float, y: float) -> float:
9 return x / y
10
11def is_active(user: bool) -> bool:
12 return user is not None
13
14def no_return() -> None:
15 print("This function returns None")
16
17# Variables (Python 3.6+)
18count: int = 0
19name: str = "Alice"
20pi: float = 3.14159
21active: bool = True
22result: None = None
23
24# Python ignores annotations at runtime — this still works:
25count = "not a number" # mypy would warn, runtime runs fine
annotations.py
Python
1# Multiple annotations (PEP 484 — deprecated in favor of tuple)
2# Parameter with default value
3def multiply(a: int, b: int = 2) -> int:
4 return a * b
5
6# Positional-only and keyword-only (PEP 570/3102)
7def 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
11def 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

Always annotate function parameters and return types — they serve as documentation checked by machines. Variable annotations are optional but valuable for complex types or module-level constants.
Container Type Annotations

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.

Container3.8- Style (typing)3.9+ Style
ListList[int]list[int]
DictDict[str, int]dict[str, int]
TupleTuple[int, ...]tuple[int, ...]
SetSet[str]set[str]
FrozensetFrozenset[int]frozenset[int]
containers.py
Python
1# Python 3.9+ — preferred (built-in generics)
2from collections.abc import Sequence, Mapping
3
4def process(items: list[int]) -> None:
5 for item in items:
6 print(item)
7
8def lookup(table: dict[str, int], key: str) -> int | None:
9 return table.get(key)
10
11def 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
16def coords() -> tuple[float, ...]:
17 return (1.0, 2.0, 3.0, 4.0)
18
19# Use abstract types for function parameters
20from collections.abc import Sequence, Mapping
21
22def total(values: Sequence[float]) -> float:
23 return sum(values) # accepts list, tuple, range, etc.
24
25def show(config: Mapping[str, str]) -> None:
26 for k, v in config.items():
27 print(f"{k}: {v}")
28
29# Python 3.8- compatibility
30from typing import List, Dict, Tuple, Set, Optional
31
32old_style: List[int] = [1, 2, 3]
33old_dict: Dict[str, int] = {"a": 1}

best practice

Use built-in generics (list[int], dict[str, int]) for Python 3.9+. For function parameters, prefer abstract types like Sequence and Mapping over concrete list and dict — they accept more input types.
Optional, Union & None Handling

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."

optional_union.py
Python
1# Optional — equivalent to Union[X, None]
2from typing import Optional
3
4def 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)
9def find_user_v2(uid: int) -> str | None:
10 return {1: "Alice", 2: "Bob"}.get(uid)
11
12# Union — one of several types
13from typing import Union
14
15def 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
21def parse_id_v2(value: int | str) -> int:
22 return int(value) if isinstance(value, str) else value
23
24# Multiple unions
25def 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
37def 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

The X | None syntax (PEP 604) is cleaner than Optional[X] and matches how you think about the type. It works in isinstance() checks too: isinstance(x, int | str).
TypeVar, Generics & TypeAlias

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

typevar.py
Python
1from typing import TypeVar
2
3# Single type variable
4T = TypeVar("T")
5
6def first(items: list[T]) -> T:
7 return items[0]
8
9# Type is preserved:
10revealed = first([1, 2, 3]) # inferred: int
11revealed = first(["a", "b"]) # inferred: str
12
13# Bounded TypeVar — restricts to str or subclasses
14StrType = TypeVar("StrType", bound=str)
15
16def concat(a: StrType, b: StrType) -> StrType:
17 return a + b # type-checker knows it's a string
18
19# Constrained TypeVar — only specific types
20NumType = TypeVar("NumType", int, float)
21
22def double(x: NumType) -> NumType:
23 return x * 2
24
25double(5) # ok
26double(3.0) # ok
27double("a") # type error
28
29# Multiple TypeVars
30K = TypeVar("K")
31V = TypeVar("V")
32
33def get_or_default(d: dict[K, V], key: K, default: V) -> V:
34 return d.get(key, default)

Generic Classes

generic_class.py
Python
1from typing import Generic, TypeVar
2
3T = TypeVar("T")
4
5# Generic class — akin to templates in other languages
6class 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
24int_stack = Stack[int]()
25int_stack.push(1)
26int_stack.push(2)
27val = int_stack.pop() # inferred: int
28
29str_stack = Stack[str]()
30str_stack.push("hello")
31val2 = str_stack.pop() # inferred: str
32
33# Multiple type parameters
34class 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+)

type_alias.py
Python
1from typing import TypeAlias
2
3# Python 3.10+ — explicit type alias
4Vector: TypeAlias = list[float]
5Matrix: TypeAlias = list[Vector]
6JSON: TypeAlias = str | int | float | bool | None | dict[str, "JSON"] | list["JSON"]
7
8def scale(v: Vector, factor: float) -> Vector:
9 return [x * factor for x in v]
10
11def transpose(m: Matrix) -> Matrix:
12 return list(map(list, zip(*m)))
13
14# Python 3.12+ — type statement (no import needed)
15type Vector2 = list[float]
16
17type Point3D = tuple[float, float, float]
18
19def 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)
23from typing import TypeAlias
24
25JSONValue: TypeAlias = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]

Concise Generics with [T] Syntax (3.12+)

concise_generics.py
Python
1# Python 3.12+ — PEP 695: concise generic syntax
2# No TypeVar import needed!
3
4def first[T](items: list[T]) -> T: # instead of TypeVar + def
5 return items[0]
6
7class 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
18def swap[K, V](key: K, value: V) -> tuple[V, K]:
19 return (value, key)
20
21class 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
27def first_str[T: str](items: list[T]) -> T:
28 return items[0]
29
30# Type alias with type parameters
31type Tree[T] = T | list["Tree[T]"] # recursive type alias
32
33# This is the modern, idiomatic way since 3.12.
Callable

Callable types describe the signature of functions, lambdas, and other callable objects. It takes a list of argument types and a return type.

callable.py
Python
1from collections.abc import Callable
2
3# Callable[[ArgType1, ArgType2, ...], ReturnType]
4
5# Function that takes two ints and returns an int
6def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
7 return fn(a, b)
8
9result = apply(lambda x, y: x + y, 3, 4) # 7
10result = apply(lambda x, y: x * y, 3, 4) # 12
11
12# Callable with no arguments
13def run(fn: Callable[[], str]) -> None:
14 print(fn())
15
16# Callable with variable arguments
17Callback = Callable[..., None] # any args, returns None
18
19def register(handler: Callback) -> None:
20 handler("any", "args", 42) # ok
21
22# Protocol for more complex signatures
23from typing import Protocol
24
25class IntOperator(Protocol):
26 def __call__(self, a: int, b: int) -> int: ...
27
28def compute(op: IntOperator, x: int, y: int) -> int:
29 return op(x, y)
30
31compute(lambda a, b: a + b, 1, 2) # ok — structurally matches
32
33# TypeVar for generic callbacks
34T = TypeVar("T")
35
36def map_items(items: list[T], fn: Callable[[T], T]) -> list[T]:
37 return [fn(x) for x in items]

info

For simple callbacks, Callable is sufficient. For callbacks with complex signatures (keyword args, overloads), use a Protocol with __call__ — it's more expressive and reusable.
TypedDict

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.

typed_dict.py
Python
1from typing import TypedDict
2
3# Class-based syntax (Python 3.8+)
4class User(TypedDict):
5 name: str
6 age: int
7 email: str
8
9def create_user(name: str, age: int) -> User:
10 return {"name": name, "age": age, "email": f"{name}@example.com"}
11
12user = create_user("Alice", 30)
13print(user["name"]) # ok
14print(user["age"]) # ok
15print(user["unknown"]) # type error — key doesn't exist
16
17# Dict-literal syntax (Python 3.11+)
18from typing import TypedDict, Unpack
19
20Movie = TypedDict("Movie", {"title": str, "year": int})
21
22# Optional keys — use total=False
23class Config(TypedDict, total=False):
24 debug: bool
25 timeout: float
26 host: str
27
28# All keys are optional:
29cfg: Config = {} # ok
30cfg2: Config = {"debug": True, "host": "localhost"} # ok
31
32# Mix required and optional (Python 3.11+)
33class 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
40class PartialRequest(Request, total=False):
41 timeout: float
42 retry: bool
43
44# Unpack — spread TypedDict into function kwargs (3.11+)
45from typing import Unpack
46
47def register_user(**kwargs: Unpack[User]) -> None:
48 print(f"Registering {kwargs['name']}")
49
50register_user(name="Bob", age=25, email="bob@example.com")

best practice

Use TypedDict when you need dictionary-like access with string keys (JSON APIs, configuration). Use dataclasses when you need object-oriented access with attributes and methods. They serve different purposes.
Protocol — Structural Subtyping

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.

protocol.py
Python
1from typing import Protocol
2
3# Define a protocol — what methods must exist
4class Drawable(Protocol):
5 def draw(self) -> None: ...
6
7# These classes satisfy Drawable without explicit inheritance
8class Circle:
9 def draw(self) -> None:
10 print("Drawing circle")
11
12class Square:
13 def draw(self) -> None:
14 print("Drawing square")
15
16class Triangle:
17 def draw(self) -> None:
18 print("Drawing triangle")
19
20# Function accepts anything that satisfies Drawable
21def render(shapes: list[Drawable]) -> None:
22 for shape in shapes:
23 shape.draw() # type-checker is satisfied
24
25render([Circle(), Square(), Triangle()]) # all ok
26
27# Protocols with attributes
28class HasName(Protocol):
29 name: str
30
31def greet(obj: HasName) -> str:
32 return f"Hello, {obj.name}"
33
34class Person:
35 def __init__(self, name: str) -> None:
36 self.name = name
37
38class Pet:
39 def __init__(self, name: str) -> None:
40 self.name = name
41
42greet(Person("Alice")) # ok
43greet(Pet("Rex")) # ok — both have .name
44
45# Runtime checkable (limited)
46from typing import runtime_checkable
47
48@runtime_checkable
49class SupportsClose(Protocol):
50 def close(self) -> None: ...
51
52print(isinstance(open("/dev/null"), SupportsClose)) # True
protocol_advanced.py
Python
1# Iterator protocol example
2from typing import Protocol, TypeVar
3
4T = TypeVar("T", covariant=True)
5
6class Iterable(Protocol[T]):
7 def __iter__(self) -> Iterator[T]: ...
8
9class Iterator(Iterable[T], Protocol[T]):
10 def __next__(self) -> T: ...
11 def __iter__(self) -> "Iterator[T]": ...
12
13# Generic protocol — reusable across types
14class Comparable(Protocol):
15 def __lt__(self, other: object) -> bool: ...
16
17def max_item[T: Comparable](items: list[T]) -> T:
18 return max(items)
19
20# Protocol composition — use inheritance
21class Readable(Protocol):
22 def read(self) -> str: ...
23
24class Writable(Protocol):
25 def write(self, data: str) -> None: ...
26
27class ReadWrite(Readable, Writable, Protocol):
28 """Combines both protocols."""
29 ...
30
31# Use with TypeGuard for runtime narrowing
32from typing import TypeGuard
33
34def is_drawable(obj: object) -> TypeGuard[Drawable]:
35 return hasattr(obj, "draw")

best practice

Protocols enable "duck typing with safety." They are especially valuable for library code where you want to accept any object that supports certain operations without forcing users to inherit from your base classes.
@overload — Multiple Signatures

@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.

overload.py
Python
1from typing import overload
2
3# Overloads — each is a type signature
4@overload
5def process(item: int) -> int: ...
6
7@overload
8def process(item: str) -> list[str]: ...
9
10@overload
11def process(item: list[str]) -> dict[str, int]: ...
12
13# Implementation — handles all overload cases
14def 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
23result1 = process(42) # inferred: int
24result2 = process("hello") # inferred: list[str]
25result3 = process(["a", "bc"]) # inferred: dict[str, int]
26
27# Common pattern: dispatching on type
28@overload
29def find(key: int) -> str: ...
30
31@overload
32def find(key: str) -> int: ...
33
34def 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
40from typing import Literal
41
42@overload
43def set_mode(mode: Literal["read"]) -> str: ...
44
45@overload
46def set_mode(mode: Literal["write"]) -> int: ...
47
48@overload
49def set_mode(mode: Literal["append"]) -> float: ...
50
51def 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

Every @overload signature must be more specific than the implementation signature. The implementation signature is never checked against callers — it only needs to be compatible with all overloads at runtime.
Advanced Type Features

Python's type system includes several advanced features that handle edge cases and enable more precise typing.

Literal Types (PEP 586)

literal.py
Python
1from typing import Literal
2
3# Constrain a value to specific literals
4def set_status(status: Literal["active", "inactive", "pending"]) -> None:
5 print(f"Status set to {status}")
6
7set_status("active") # ok
8set_status("deleted") # type error
9
10# Literal with int/bool
11def open_file(mode: Literal["r", "w", "a", "rb", "wb"]) -> str:
12 return f"opened with {mode}"
13
14def set_timeout(seconds: Literal[30, 60, 120, 300]) -> None:
15 pass
16
17# Combining Literal with Union
18Action = Literal["create", "read", "update", "delete"] | str
19
20# Literal in overloads for precise return types (see @overload section)

Final — Constants (PEP 591)

final.py
Python
1from typing import Final
2
3# Variable that should never be reassigned
4MAX_RETRIES: Final = 3
5PI: Final[float] = 3.14159
6
7# Reassignment is a type error
8MAX_RETRIES = 5 # mypy error: cannot assign to final name
9
10# Final methods — subclasses cannot override
11from typing import final
12
13class Base:
14 @final
15 def close(self) -> None:
16 print("closing")
17
18class Derived(Base):
19 def close(self) -> None: # type error
20 print("override")
21
22# Final classes — cannot be subclassed
23@final
24class ImmutablePoint:
25 def __init__(self, x: float, y: float) -> None:
26 self.x = x
27 self.y = y
28
29class ExtendedPoint(ImmutablePoint): # type error
30 pass

Self — Return Self Type (3.11+)

self_type.py
Python
1from typing import Self
2
3# Self type — return the actual subclass type
4class Shape:
5 def set_color(self, color: str) -> Self:
6 self.color = color
7 return self
8
9class Circle(Shape):
10 def set_radius(self, r: float) -> Self:
11 self.radius = r
12 return self
13
14# Method chaining works with subtypes
15circle = Circle()
16circle.set_color("red").set_radius(5.0) # ok — returns Circle, not Shape
17
18# Self in classmethods
19class 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)
29T = TypeVar("T", bound="Shape")
30
31class 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

newtype.py
Python
1from typing import NewType
2
3# Create a distinct type (zero runtime overhead)
4UserId = NewType("UserId", int)
5ProductId = NewType("ProductId", int)
6
7def lookup_user(uid: UserId) -> str:
8 return f"user_{uid}"
9
10def lookup_product(pid: ProductId) -> str:
11 return f"product_{pid}"
12
13# These are incompatible at type-checking time
14user = UserId(42)
15product = ProductId(99)
16
17lookup_user(user) # ok
18lookup_user(product) # type error — ProductId != UserId
19
20# NewType wraps the original — operations still work
21val: int = user + 1 # ok — UserId is-a int for operations
22
23# But you can pass int where NewType is expected
24lookup_user(42) # type error — int != UserId
25
26# NewType at runtime is just the identity function
27print(type(UserId(42))) # <class 'int'> — no wrapper at runtime

Any — The Escape Hatch

any_type.py
Python
1from typing import Any
2
3# Any opts out of type checking entirely
4def process(data: Any) -> Any:
5 return data.does_not_exist() # no error
6
7# Any is infectious — avoid unless necessary
8def 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
13def migrate(initial: Any) -> str:
14 # During migration, accept anything
15 return str(initial)
16
17# typed_ast — cast when you know better
18from typing import cast
19
20def 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
25x: int = "hello" # type: ignore # explicitly opting out

warning

Any is the escape valve from the type system. It should be temporary — either during migration from an untyped codebase or when interfacing with dynamic libraries. Overuse of Any defeats the purpose of type hints entirely.
Tooling — mypy & pyright

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.

tooling_config.py
Python
1# mypy configuration — mypy.ini or pyproject.toml
2
3# mypy.ini
4[mypy]
5python_version = 3.12
6strict = True # enable all optional checks
7ignore_missing_imports = True # skip libraries without stubs
8disallow_untyped_defs = True # require annotations on all functions
9disallow_any_unimported = False
10no_implicit_optional = True # require explicit Optional
11warn_redundant_casts = True
12warn_unused_ignores = True
13warn_return_any = True
14strict_equality = True # warn on == between incompatible types
15
16# Per-module overrides
17[mypy-plugins.*]
18ignore_errors = True
19
20# pyproject.toml equivalent (PEP 621)
21[tool.mypy]
22python_version = "3.12"
23strict = true
24ignore_missing_imports = true
25
26# pyright configuration — pyproject.toml
27[tool.pyright]
28typeCheckingMode = "strict" # "basic" | "standard" | "strict"
29include = ["src"]
30exclude = ["tests", "venv"]
31pythonVersion = "3.12"
32reportMissingTypeStubs = false
33reportMissingParameterType = 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
gradual_typing.py
Python
1# Gradual typing — start somewhere
2# Step 1: annotate public functions
3def 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]
14result = some_third_party_func() # type: ignore[return-value]
15
16# Check environment — see what version of typing features are available
17import sys
18print(sys.version_info >= (3, 10)) # union syntax: int | str
19print(sys.version_info >= (3, 11)) # Self, Unpack
20print(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

Start with mypy --strict on new projects. For existing codebases, enable disallow_untyped_defs = True module by module. Use # type: ignore as a temporary bridge, never as a permanent solution.
Best Practices

Effective type hinting is a skill that balances correctness with readability. These guidelines help you write maintainable, well-typed Python code.

GuidelineWhy
Prefer | over Union/OptionalCleaner since 3.10
Use Sequence over listAccepts tuples, ranges, other sequences
Protocol over ABCStructural typing is more flexible
Strict mypy configCatches the most errors
Avoid AnyDefeats the purpose of type checking
Use NewType for IDsPrevents passing wrong ID types
Self over TypeVar for methodsCleaner and correct for chaining
TypedDict for JSON shapesRuntime plain dict, statically checked
Concise [T] syntax on 3.12+Less boilerplate, modern style
Run mypy in CICatches regressions automatically
best_practices.py
Python
1# Good: specific, narrow types
2def fetch_user(db: Database, uid: UserId) -> User | None:
3 ...
4
5# Bad: overuse of Any
6def fetch_user(db: Any, uid: Any) -> Any:
7 ...
8
9# Good: use Protocol for interfaces
10class Logger(Protocol):
11 def log(self, message: str) -> None: ...
12
13# Bad: forcing inheritance
14class Logger(ABC):
15 @abstractmethod
16 def log(self, message: str) -> None: ...
17
18# Good: TypedDict for API responses
19class APIResponse(TypedDict):
20 status: Literal["ok", "error"]
21 data: dict[str, Any]
22 error: str | None
23
24# Good: type alias for complex types
25JSONDict: TypeAlias = dict[str, "JSONValue"]
26
27# Good: use Final for configuration constants
28DEFAULT_TIMEOUT: Final[float] = 30.0
29
30# Good: overload for dispatching on input type
31@overload
32def load(path: str) -> dict[str, Any]: ...
33@overload
34def 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

The goal of type hints is to catch bugs, document intent, and enable better IDE support. A type system should serve your code, not constrain it. When a type annotation becomes a puzzle, step back and simplify the function instead.
$Blueprint — Engineering Documentation·Section ID: PYTHON-TYPE·Revision: 1.0