Overview

Type hints are the contract between caller and callee, the input to a strict checker, and the runtime source for Pydantic validators and fastapi schemas. Use the newest syntax your minimum Python allows. Project setup is in python.

FeaturePEPPython
Self6733.11
def f[T], class C[T], type X = ...6953.12
@override6983.12
TypeVar defaults ([T = int])6963.13
TypeIs, ReadOnly TypedDict items742, 7053.13
Deferred annotation evaluation649, 7493.14

Annotate every cross-module function

Annotate parameters and the return type on anything public. Take collections.abc types (Sequence, Mapping, Callable, Iterable) as parameters and return concrete types when callers need the concrete API.

Prefer Protocol over ABC for interfaces

A Protocol is satisfied structurally, including by classes you cannot modify. Use an ABC only when subclasses share implementation through super().

class Serializable(Protocol):
    def to_json(self) -> str: ...
 
def save(obj: Serializable) -> None: ...

Add @runtime_checkable only if you call isinstance on it; that check verifies member names, not signatures.

Use PEP 695 syntax for generics and aliases

On 3.12+ declare type parameters inline instead of module-level TypeVars. On 3.13+ a parameter can carry a default.

from collections.abc import Callable
 
def first[T](items: list[T]) -> T | None:
    return items[0] if items else None
 
def logged[**P, R](fn: Callable[P, R]) -> Callable[P, R]: ...
 
class Box[T = int]:
    def __init__(self, value: T) -> None:
        self.value = value
 
type Row = tuple[str, int, float]

A type alias is evaluated lazily, so it can refer to itself or to names defined later. Fall back to TypeAlias and TypeVar only when you must support 3.11 or older.

Return Self and mark overrides

Self types methods that return the receiver’s own class, so subclasses keep their type in chains. @override (3.12) makes the checker fail when a rename leaves a method overriding nothing.

Narrow with TypeIs, fall back to TypeGuard

TypeIs[T] (3.13) narrows both branches, but the narrowed type must be assignable to the input type. TypeGuard[T] narrows only the if branch and has no such requirement, so use it when the result is not a subtype, such as list[object] to list[str]. Both beat cast(), which silences the checker without checking anything.

from typing import TypeIs
 
def is_str(val: object) -> TypeIs[str]:
    return isinstance(val, str)

Attach metadata with Annotated

Annotated[T, ...] carries validation or documentation without changing the type. Pydantic v2 and FastAPI read it for constraints; define each constrained alias once (PositiveInt = Annotated[int, Field(gt=0)]) and reuse it.

Drop forward-reference quotes on 3.14

Python 3.14 evaluates annotations lazily (PEP 649, 749), so class A: b: B works before B exists with no quotes. from __future__ import annotations still works; PEP 749 schedules a deprecation warning only after 3.13 reaches end of life. If your code must run on 3.13 or older, keep the future import or the quotes.

Introspect annotations with the right API

On 3.14 use annotationlib.get_annotations(obj, format=Format.FORWARDREF), which returns ForwardRef objects for names that do not resolve yet instead of raising NameError. typing.get_type_hints(obj) still resolves everything to real types and suits runtime validators on any version. Do not read __annotations__ directly.