Overview

Type hints in Python 3.12+ are not optional. They are the contract between caller and callee, the input to pyright strict mode, and the runtime source for Pydantic validators and FastAPI schemas. Read python for the project-level setup; this page covers the type system itself.

Annotate every public function boundary

Every parameter and return type on a public or cross-module function must have an annotation. Internal helpers can rely on pyright’s local inference, but the moment a function crosses a module boundary, annotate explicitly.

from collections.abc import Sequence
 
def paginate(items: Sequence[str], page: int, size: int) -> list[str]:
    start = page * size
    return list(items[start : start + size])

Use collections.abc types (Sequence, Mapping, Callable, Iterator) for parameters. Concrete types (list, dict) are fine for return types when the caller needs the concrete API.

Prefer Protocol over ABC for structural typing

Protocol lets you define a structural interface without inheritance. Any class that implements the required methods satisfies the protocol, even third-party classes you cannot modify.

from typing import Protocol, runtime_checkable
 
@runtime_checkable
class Serializable(Protocol):
    def to_json(self) -> str: ...
 
def save(obj: Serializable) -> None:
    with open("out.json", "w") as f:
        f.write(obj.to_json())

Use ABC only when you need shared implementation via super(). For interface-only contracts, Protocol is strictly better because callers avoid the inheritance dependency.

Use TypeVar and ParamSpec for generic functions

TypeVar creates a type parameter that the checker can track across a call. ParamSpec (PEP 612) captures the full signature of a callable so decorator types stay accurate.

from typing import TypeVar
from collections.abc import Callable
 
T = TypeVar("T")
 
def first(items: list[T]) -> T | None:
    return items[0] if items else None
 
 
from typing import ParamSpec
import functools
 
P = ParamSpec("P")
R = TypeVar("R")
 
def logged(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

In Python 3.12+, prefer the new type statement syntax for simple aliases and the [T] syntax on class definitions instead of explicit TypeVar declarations.

Name type aliases with type or TypeAlias

A bare assignment like UserId = int looks like a variable, not a type alias. Use type UserId = int (Python 3.12+) or TypeAlias (3.10+) to make the intent explicit and give pyright the context to check it correctly.

# Python 3.12+
type UserId = int
type Row = tuple[str, int, float]
 
# Python 3.10-3.11 fallback
from typing import TypeAlias
UserId: TypeAlias = int

Use Annotated for metadata-carrying types

Annotated[T, metadata] attaches validation, serialization, or documentation metadata to a type without changing its Python identity. Pydantic v2 uses Annotated for field constraints.

from typing import Annotated
from pydantic import Field, BaseModel
 
PositiveInt = Annotated[int, Field(gt=0)]
ShortStr = Annotated[str, Field(max_length=100)]
 
class Invoice(BaseModel):
    id: PositiveInt
    description: ShortStr

Centralizing constraints in Annotated aliases means one change propagates everywhere the alias is used. See fastapi for how FastAPI reads these annotations for automatic schema generation.

Narrow types with TypeGuard and TypeIs

TypeGuard[T] (PEP 647) marks a predicate function as a type narrowing guard. TypeIs[T] (PEP 742, Python 3.13+) is the stricter form that also narrows the False branch.

from typing import TypeGuard
 
def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in val)
 
def process(items: list[object]) -> None:
    if is_str_list(items):
        # items is list[str] here
        print(", ".join(items))

Prefer TypeGuard over cast(). A guard expresses the check in code; a cast silences the checker without verifying anything. Reserve cast() for cases where you have proof the checker cannot see.

Introspect annotations at runtime with get_type_hints

typing.get_type_hints(cls) resolves forward references and returns the evaluated annotations dictionary. Use it when you need the type information at runtime, for example in a custom serialization layer.

import typing
 
class Config:
    host: str
    port: int
    debug: bool = False
 
hints = typing.get_type_hints(Config)
# {"host": <class 'str'>, "port": <class 'int'>, "debug": <class 'bool'>}

Avoid reading __annotations__ directly; it does not resolve string annotations. Use get_type_hints instead.

Use Self for methods that return the same type

Self (PEP 673, Python 3.11+) annotates methods that return an instance of the class, including subclasses. It replaces the fragile TypeVar("T", bound="MyClass") pattern.

from typing import Self
 
class Builder:
    def set_name(self, name: str) -> Self:
        self._name = name
        return self
 
class AdvancedBuilder(Builder):
    pass
 
# AdvancedBuilder().set_name("x") returns AdvancedBuilder, not Builder