Static typing with mypy and pyright
Generics, TypeVar, ParamSpec, Protocols, and how to reach mypy --strict on a real codebase.
Why this matters
In 2026 every serious Python codebase is typed end-to-end. Type hints catch a class of bugs before runtime, power IDE autocomplete, document intent better than comments, and unlock features like pydantic's field validation and FastAPI's automatic OpenAPI. This lesson takes you from "I sprinkle some hints" to "I write generic, type-safe APIs."
Learning objectives
- Use modern syntax (
list[int],int | None). - Use
TypeVar,Generic,Protocol,ParamSpec. - Write
overloads for polymorphic functions. - Use
typing.Self,TypedDict,Literal,Annotated. - Configure
mypy/pyrightstrict mode.
1. The basics (recap)
x: int = 1
y: float = 1.5
name: str = "Ada"
ok: bool = True
nothing: None = None
names: list[str] = ["a", "b"]
counts: dict[str, int] = {"a": 1}
coords: tuple[float, float] = (1.0, 2.0)
opts: set[str] = {"a", "b"}
# 3.10+
maybe_int: int | None = None # was Optional[int]
either: int | str = 1 # was Union[int, str]
# Type aliases
UserId = int # plain alias (3.0+)
from typing import TypeAlias
UserId: TypeAlias = int # explicit (3.10+)
type UserId = int # 3.12+ syntax (PEP 695)Built-in generic syntax (list[int]) replaced List[int] everywhere in 3.9+. Use it.
2. Functions
def greet(name: str, *, loud: bool = False) -> str:
return name.upper() if loud else name
# No return
def log(msg: str) -> None: ...
# Never returns (raises / exits)
from typing import NoReturn
def crash() -> NoReturn: raise RuntimeError
# Callable
from collections.abc import Callable
Handler = Callable[[int, str], bool] # takes int, str โ bool
def run(h: Handler) -> None: ...
# Variadic
def add(*nums: int) -> int: return sum(nums)
def merge(**kwargs: str) -> dict[str, str]: return dict(kwargs)3. TypeVar and Generics
from typing import TypeVar
T = TypeVar("T")
def first(xs: list[T]) -> T:
return xs[0]
first([1, 2, 3]) # T inferred as int
first(["a", "b"]) # T inferred as strBounded
from typing import TypeVar
from numbers import Real
N = TypeVar("N", bound=Real)
def double(x: N) -> N: return x * 2bound=Real means "T must be a subclass of Real." (More precisely: a subtype.)
Constrained (a fixed set)
S = TypeVar("S", str, bytes)
def repeat(x: S) -> S: return x + xGeneric class
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None: self._items: list[T] = []
def push(self, item: T) -> None: self._items.append(item)
def pop(self) -> T: return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
s.push("x") # mypy error: Argument has incompatible type "str"PEP 695 syntax (3.12+)
class Stack[T]: # no Generic, no TypeVar
def __init__(self) -> None: self._items: list[T] = []
def push(self, item: T) -> None: self._items.append(item)
def first[T](xs: list[T]) -> T: return xs[0]
type Pair[T] = tuple[T, T]Cleaner. Prefer this in new 3.12+ code.
4. Protocol (recap, with generics)
from typing import Protocol
class SupportsAdd(Protocol):
def __add__(self, other, /): ...
def double[T: SupportsAdd](x: T) -> T:
return x + x
double(3) # 6
double("ab") # "abab"
double([1, 2]) # [1, 2, 1, 2]Structural typing โ no inheritance needed.
5. Optional / Union / Literal / Annotated
from typing import Literal, Annotated
# Optional (preferred: X | None)
def find(uid: int) -> User | None: ...
# Union (preferred: A | B)
def parse(x: int | str) -> int: ...
# Literal โ exact values
def set_mode(mode: Literal["read", "write", "append"]) -> None: ...
# Annotated โ attach metadata used by libraries
from pydantic import Field
class M(BaseModel):
age: Annotated[int, Field(ge=0, lt=150)]
# typing.NewType โ distinct alias (compile-time only)
from typing import NewType
UserId = NewType("UserId", int)
def find_user(uid: UserId) -> User: ...
find_user(42) # mypy error: Argument has incompatible type "int"
find_user(UserId(42)) # ok6. TypedDict โ typed dictionary
from typing import TypedDict, NotRequired, Required
class UserDict(TypedDict):
name: str
age: int
email: NotRequired[str] # optional key (3.11+)
u: UserDict = {"name": "Ada", "age": 30}Two construction styles:
class UserDict(TypedDict, total=False): # all keys optional
name: str
age: int
class UserDict(TypedDict): # all keys required by default
name: Required[str]
age: int
email: NotRequired[str]Use for JSON payloads, API responses, anywhere you have a dict-shaped record but don't want a class.
7. Self โ return type for fluent APIs
from typing import Self
class Builder:
def add(self, x: int) -> Self:
...
return self
class SubBuilder(Builder):
pass
SubBuilder().add(1) # type: SubBuilder, not BuilderBefore Self (3.11+), you'd use TypeVar("S", bound="Builder") โ verbose. Always use Self now.
8. ParamSpec โ typed decorators
from typing import ParamSpec, TypeVar, Callable
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper
@timed
def slow(x: int, y: str) -> bool: ...
slow(1, "a") # mypy keeps the original signature
slow("bad", "a") # error: x must be intP.args and P.kwargs capture positional and keyword args of the wrapped function. Without ParamSpec, decorators erase the signature to (*args: Any, **kwargs: Any) -> Any.
9. overload โ polymorphic signatures
Static-only multi-signature description:
from typing import overload
@overload
def get(key: str) -> str: ...
@overload
def get(key: str, default: int) -> str | int: ...
def get(key: str, default=None):
... # the real implementationThe @overload declarations have no body. Only the final un-decorated definition is real. Type checkers pick the matching overload at the call site.
Used in stdlib (open returns text or binary based on mode) and in libraries (pandas DataFrame slicing).
10. Final, ClassVar, Never
from typing import Final, ClassVar, Never
class Settings:
PI: Final = 3.14159 # cannot be reassigned (type-checker enforced)
instances: ClassVar[int] = 0 # class-level, not per-instance
def panic() -> Never: raise RuntimeError # same as NoReturnNever (3.11+) is the bottom type โ "no value." Useful for exhaustive match:
def assert_never(value: Never) -> Never:
raise AssertionError(f"unhandled: {value}")
match shape:
case Circle(): ...
case Square(): ...
case _: assert_never(shape) # type-checker complains if a case is missing11. cast and reveal_type
from typing import cast
x = json.loads(raw) # type is `Any`
data = cast(list[dict], x) # tell the type-checker (no runtime check)Use cast sparingly โ it's an unchecked promise.
reveal_type(some_var) # mypy / pyright print the inferred type; not a real functionAvailable in mypy/pyright during checking; remove before runtime (it's not imported).
12. Annotated deeper โ runtime metadata
from typing import Annotated
import re
NonEmpty = Annotated[str, lambda s: len(s) > 0]
EmailLike = Annotated[str, re.compile(r"^[^@]+@[^@]+$")]
class M(BaseModel):
name: NonEmptyLibraries like pydantic, fastapi, typer read these metadata at runtime via typing.get_type_hints(include_extras=True). You can attach validators, dependencies, transforms.
13. Configure mypy / pyright for strict mode
pyproject.toml:
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
warn_return_any = true
warn_unreachable = true
disallow_untyped_defs = true
disallow_untyped_calls = true
no_implicit_optional = true
plugins = ["pydantic.mypy"]For pyright (pyrightconfig.json):
{
"pythonVersion": "3.12",
"typeCheckingMode": "strict",
"reportMissingTypeStubs": "warning",
"reportUnknownArgumentType": "error",
"reportUnknownVariableType": "error"
}Start strict; add per-file # mypy: ignore-errors as escape hatches while migrating.
14. Common patterns
Type-safe dict access (TypedDict)
class UserDict(TypedDict): name: str; age: int
def render(u: UserDict) -> str:
return f"{u['name']} ({u['age']})"Singleton sentinel
class _MISSING: pass
MISSING: Final = _MISSING()
def get(d, k, default: Any = MISSING):
...Tagged unions / discriminated unions
from typing import Literal
class Circle(BaseModel): kind: Literal["circle"] = "circle"; radius: float
class Square(BaseModel): kind: Literal["square"] = "square"; side: float
Shape = Circle | Square
def area(s: Shape) -> float:
match s:
case Circle(radius=r): return 3.14 * r * r
case Square(side=a): return a * aThe Literal discriminator lets Pydantic / type checkers route correctly.
Hands-on lab (2 hours)
- Add full type hints (no
Any) to a 100-line script you've written. Runmypy --strict. Fix the errors. - Implement a generic
Cache[K, V]class withget(k),put(k, v),clear(). Test it with two types. - Write
@retrydecorator withParamSpecso signatures are preserved. - Define a
TypedDictfor a JSON payload your app handles; replacedictparameters with it. - Use
Literal+matchto write a small dispatcher withassert_never-checked exhaustiveness. - Convert a class hierarchy to use
Selffor fluent return types. - Bonus: rewrite a generic class using PEP 695 syntax (
class Stack[T]).
Common pitfalls
- Putting type hints inside docstrings (the rare jobs that still do this โ convert).
- Using
List,Dict,Tuplefromtypinginstead of built-ins. Optional[X]instead ofX | None(cosmetic; either works).castto silence mypy โ sometimes correct, often hiding bugs.- Wrapping a typed function in an untyped decorator โ signature collapses to
(*Any, **Any) -> Any. UseParamSpec. - Generic protocols without
@runtime_checkableand then tryingisinstance.
Self-check
- Difference between
Optional[X]andX | None. - What does
ParamSpecsolve? - When use
ProtocolvsABC? - What is
Annotatedfor? - How does
assert_neverenable exhaustiveness?
References
- PEP 484 โ Type hints.
- PEP 526 โ Variable annotations.
- PEP 544 โ Protocols.
- PEP 585 โ Built-in generics (
list[int]). - PEP 604 โ Union with
|. - PEP 612 โ
ParamSpec. - PEP 646 โ Variadic generics (TypeVarTuple).
- PEP 673 โ
Selftype. - PEP 695 โ Type parameter syntax (3.12).
- mypy docs: https://mypy.readthedocs.io/.
- pyright docs: https://microsoft.github.io/pyright/.
Sign in to save your progress and earn badges.