Static typing with mypy and pyright

Generics, TypeVar, ParamSpec, Protocols, and how to reach mypy --strict on a real codebase.

๐Ÿง  Module 3 9 min read Not started

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

  1. Use modern syntax (list[int], int | None).
  2. Use TypeVar, Generic, Protocol, ParamSpec.
  3. Write overloads for polymorphic functions.
  4. Use typing.Self, TypedDict, Literal, Annotated.
  5. Configure mypy / pyright strict mode.

1. The basics (recap)

python
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

python
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

python
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 str

Bounded

python
from typing import TypeVar
from numbers import Real

N = TypeVar("N", bound=Real)
def double(x: N) -> N: return x * 2

bound=Real means "T must be a subclass of Real." (More precisely: a subtype.)

Constrained (a fixed set)

python
S = TypeVar("S", str, bytes)
def repeat(x: S) -> S: return x + x

Generic class

python
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+)

python
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)

python
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

python
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))          # ok

6. TypedDict โ€” typed dictionary

python
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:

python
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

python
from typing import Self

class Builder:
    def add(self, x: int) -> Self:
        ...
        return self

class SubBuilder(Builder):
    pass

SubBuilder().add(1)              # type: SubBuilder, not Builder

Before Self (3.11+), you'd use TypeVar("S", bound="Builder") โ€” verbose. Always use Self now.


8. ParamSpec โ€” typed decorators

python
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 int

P.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:

python
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 implementation

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

python
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 NoReturn

Never (3.11+) is the bottom type โ€” "no value." Useful for exhaustive match:

python
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 missing

11. cast and reveal_type

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

python
reveal_type(some_var)            # mypy / pyright print the inferred type; not a real function

Available in mypy/pyright during checking; remove before runtime (it's not imported).


12. Annotated deeper โ€” runtime metadata

python
from typing import Annotated
import re

NonEmpty = Annotated[str, lambda s: len(s) > 0]
EmailLike = Annotated[str, re.compile(r"^[^@]+@[^@]+$")]

class M(BaseModel):
    name: NonEmpty

Libraries 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:

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):

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)

python
class UserDict(TypedDict): name: str; age: int

def render(u: UserDict) -> str:
    return f"{u['name']} ({u['age']})"

Singleton sentinel

python
class _MISSING: pass
MISSING: Final = _MISSING()
def get(d, k, default: Any = MISSING):
    ...

Tagged unions / discriminated unions

python
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 * a

The Literal discriminator lets Pydantic / type checkers route correctly.


Hands-on lab (2 hours)

  1. Add full type hints (no Any) to a 100-line script you've written. Run mypy --strict. Fix the errors.
  2. Implement a generic Cache[K, V] class with get(k), put(k, v), clear(). Test it with two types.
  3. Write @retry decorator with ParamSpec so signatures are preserved.
  4. Define a TypedDict for a JSON payload your app handles; replace dict parameters with it.
  5. Use Literal + match to write a small dispatcher with assert_never-checked exhaustiveness.
  6. Convert a class hierarchy to use Self for fluent return types.
  7. Bonus: rewrite a generic class using PEP 695 syntax (class Stack[T]).

Common pitfalls

  1. Putting type hints inside docstrings (the rare jobs that still do this โ€” convert).
  2. Using List, Dict, Tuple from typing instead of built-ins.
  3. Optional[X] instead of X | None (cosmetic; either works).
  4. cast to silence mypy โ€” sometimes correct, often hiding bugs.
  5. Wrapping a typed function in an untyped decorator โ€” signature collapses to (*Any, **Any) -> Any. Use ParamSpec.
  6. Generic protocols without @runtime_checkable and then trying isinstance.

Self-check

  1. Difference between Optional[X] and X | None.
  2. What does ParamSpec solve?
  3. When use Protocol vs ABC?
  4. What is Annotated for?
  5. How does assert_never enable 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 โ€” Self type.
  • 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.