Generics, variance, protocols, decorators, overloads, narrowing and the rest of the toolbox you need once simple annotations are in place. Examples use 3.12 syntax with the pre-3.12 equivalent where it differs.
Contents
- Generics and variance
- PEP 695 type parameters
- Protocols
- ParamSpec and Concatenate
- Overloads
- TypedDict in depth
- Narrowing: isinstance, match, TypeGuard, TypeIs
- Self, classmethods and fluent APIs
- Never, NoReturn and exhaustiveness
- TypeVarTuple and Unpack
- Annotated metadata
- dataclass_transform, override, deprecated, Final methods
- Recursive types and JSON
- Stubs and py.typed
Generics and variance
A type parameter links types across a signature: whatever goes in, comes out.
from collections.abc import Callable, Iterable, Sequence
def batched[T](items: Sequence[T], size: int) -> list[Sequence[T]]:
return [items[i:i + size] for i in range(0, len(items), size)]
def map_concurrently[T, R](fn: Callable[[T], R], items: Iterable[T]) -> list[R]:
return [fn(x) for x in items]
chunks = batched(["a", "b", "c"], 2) # list[Sequence[str]]
lengths = map_concurrently(len, ["ab", "c"]) # list[int]Variance answers "if Dog is a subtype of Animal, is Box[Dog] a subtype of Box[Animal]?"
| Variance | Rule | Examples | Why |
|---|---|---|---|
| covariant | yes | Sequence[T], Iterator[T], tuple[T, ...], frozenset[T], Mapping[K, V] (in V), return types | read-only: you only get Ts out |
| contravariant | reversed | Callable[[T], R] (in T), parameter types, a Sink[T] with only send(T) | write-only: you only put Ts in |
| invariant | no | list[T], dict[K, V], set[T], mutable attributes | read and write |
The classic error:
def mean(xs: list[float]) -> float: ...
scores: list[int] = [1, 2, 3]
mean(scores) # error: list[int] is not list[float] (invariant)
def mean(xs: Sequence[float]) -> float: ... # fix: read-only and covariantOld-style variance must be declared, and protocol checkers enforce it:
from typing import Protocol, TypeVar
T_co = TypeVar("T_co", covariant=True)
class Source(Protocol[T_co]):
def read(self) -> T_co: ...With PEP 695 type parameters on a Protocol subclass, the checker infers variance from usage.
Bounds and constraints:
| Form | Meaning | Use |
|---|---|---|
[T] | anything | containers, pass-through |
[T: Hashable] (bound=) | T is a subtype of the bound and stays that precise type | def dedupe[T: Hashable](xs: Iterable[T]) -> list[T] |
[S: (str, bytes)] (constraints) | exactly one of the listed types, never a mix | str/bytes utilities |
PEP 695 type parameters
# Functions
def first[T](xs: Sequence[T]) -> T: ...
# Classes (Generic base is implicit)
class Registry[K, V]:
def __init__(self) -> None:
self._items: dict[K, V] = {}
def register(self, key: K, value: V) -> None:
self._items[key] = value
# Aliases with parameters
type Scored[T] = list[tuple[T, float]]
type Handler[**P] = Callable[P, Awaitable[None]]
type Shape[*Ts] = tuple[*Ts]
# Defaults (3.13)
class Page[T = dict[str, object]]:
items: list[T]Rules:
- Type parameters are scoped to their function/class/alias; no module-level
TypeVarobjects. - Do not mix PEP 695 parameters with
Generic[T]on the same class. - A class's type parameters exist only inside the class body and its methods; they are not visible at module level.
TypeAliasType(typing_extensions) gives youtype-statement semantics on 3.9–3.11:JSON = TypeAliasType("JSON", "dict[str, JSON] | list[JSON] | str | int | float | bool | None").
Protocols
Structural subtyping: a class matches if it has the right members, no inheritance.
from collections.abc import Sequence
from typing import Protocol, runtime_checkable
class SupportsLen(Protocol):
def __len__(self) -> int: ...
class Tokenizer(Protocol):
@property
def vocab_size(self) -> int: ... # read-only attribute
def encode(self, text: str) -> list[int]: ...
def decode(self, ids: Sequence[int]) -> str: ...
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
def count_tokens(tok: Tokenizer, texts: Sequence[str]) -> int:
return sum(len(tok.encode(t)) for t in texts)| Detail | Rule |
|---|---|
| Attributes | name: str in a protocol means read-write; implementations with a read-only @property don't match. Use @property in the protocol for read-only |
@runtime_checkable | isinstance checks member names only, not signatures; slow-ish; avoid in hot paths |
| Generic protocol | PEP 695 parameters on a class whose base is Protocol, or Protocol[T_co] before 3.12 |
| Explicit opt-in | an implementation may subclass the protocol to get errors at the class definition; then it is nominal too |
| Callback protocol | define __call__ to express kwargs, defaults, overloads |
| Module as protocol | a module object satisfies a protocol if it has the functions (PEP 544) |
Protocol vs ABC: ABCs give runtime enforcement and shared implementation; protocols give decoupling (no import of the base class needed, third-party classes match). Prefer protocols for collaborator interfaces (embedders, stores, LLM clients) and ABCs when you ship a base implementation.
ParamSpec and Concatenate
ParamSpec captures a whole parameter list so decorators preserve it.
import functools
import time
from collections.abc import Callable
def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
print(f"{fn.__name__}: {time.perf_counter() - start:.3f}s")
return wrapper
@timed
def embed(texts: list[str], model: str = "small") -> list[list[float]]: ...
embed(["a"], model="large") # checked against the real signature
embed(["a"], modle="large") # error: unexpected keyword argumentConcatenate adds or removes leading positional parameters:
from typing import Concatenate
class Client:
...
def with_client[**P, R](fn: Callable[Concatenate[Client, P], R]) -> Callable[P, R]:
"""Inject a client as the first argument; callers no longer pass it."""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(Client(), *args, **kwargs)
return wrapper
@with_client
def ask(client: Client, prompt: str, *, temperature: float = 0.0) -> str: ...
ask("hi", temperature=0.3) # Client parameter removed from the signaturePre-3.12:
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]: ...Decorator factories (decorators with arguments) return a decorator:
def retry(attempts: int) -> Callable[[Callable[P, R]], Callable[P, R]].
Both mypy and pyright solve P and R when the returned decorator is applied.
Rules: P.args and P.kwargs must be used together as *args/**kwargs;
you cannot add keyword-only parameters with Concatenate (only positional ones).
Overloads
Use when the return type depends on argument values or types.
from typing import Literal, overload
class Stream: ...
@overload
def complete(prompt: str, *, stream: Literal[False] = False) -> str: ...
@overload
def complete(prompt: str, *, stream: Literal[True]) -> Stream: ...
@overload
def complete(prompt: str, *, stream: bool) -> str | Stream: ...
def complete(prompt: str, *, stream: bool = False) -> str | Stream:
return Stream() if stream else "done"
text = complete("hi") # str
s = complete("hi", stream=True) # StreamRules:
- At least two
@overloadsignatures, followed by one undecorated implementation (except in stubs and protocols). - The implementation signature must accept every overload's arguments, and return a supertype of each.
- Order matters: checkers pick the first match, so put narrower signatures first.
- Add the
boolcatch-all overload so callers passing a non-literalboolstill type-check. - Don't overload when a type parameter suffices (
def first[T](xs: Sequence[T]) -> T).
TypedDict in depth
from typing import Literal, NotRequired, ReadOnly, Required, TypedDict, Unpack
class Message(TypedDict):
role: Literal["system", "user", "assistant", "tool"]
content: str
name: NotRequired[str]
class GenerationOptions(TypedDict, total=False):
temperature: float
max_tokens: int
stop: list[str]
model: Required[str]
def generate(prompt: str, **opts: Unpack[GenerationOptions]) -> str: # PEP 692, 3.12
return opts["model"]
generate("hi", model="small", temperature=0.2) # keys and types checked
generate("hi", temprature=0.2) # error: unexpected keyword| Feature | Version | Notes |
|---|---|---|
total=False | 3.8 | all keys optional |
Required / NotRequired | 3.11 | per-key override |
Generic TypedDict (class Page[T] with base TypedDict) | 3.11 (3.12 syntax for [T]) | |
ReadOnly | 3.13 | key cannot be assigned through this type; allows covariant-style assignment |
Unpack[TD] for **kwargs | 3.12 | |
| Inheritance | 3.8 | class ToolMessage(Message): tool_call_id: str |
| Alternative syntax | 3.8 | TypedDict("Msg", {"from": str}) for keys that are keywords or invalid identifiers |
| Closed / extra items | typing_extensions (PEP 728) | closed=True, extra_items= |
Behaviour to remember:
- TypedDicts are plain
dicts at runtime: no validation,isinstance(x, Message)is aTypeError. - A
dict[str, str]is not assignable to a TypedDict, and a TypedDict is not assignable todict[str, object](it is toMapping[str, object]). - Reading a
NotRequiredkey directly is reported by pyright (reportTypedDictNotRequiredAccess); use.getor checkin. - Structural: two TypedDicts with the same keys are compatible.
Narrowing
Checkers narrow on: is None / is not None, isinstance, issubclass,
callable, == against literals and enum members, in against literal
collections, truthiness, len(t) == n for tuples, match patterns, and
discriminated unions on a Literal field.
from typing import Literal, TypedDict
class TextBlock(TypedDict):
type: Literal["text"]
text: str
class ToolUseBlock(TypedDict):
type: Literal["tool_use"]
name: str
input: dict[str, object]
type Block = TextBlock | ToolUseBlock
def render(block: Block) -> str:
if block["type"] == "text":
return block["text"] # narrowed to TextBlock
return f"<tool {block['name']}>" # narrowed to ToolUseBlockUser-defined predicates:
from typing import TypeGuard, TypeIs
def is_str_list(xs: list[object]) -> TypeGuard[list[str]]: # 3.10: narrows the True branch only
return all(isinstance(x, str) for x in xs)
def is_text_block(b: Block) -> TypeIs[TextBlock]: # 3.13: narrows both branches
return b["type"] == "text"
def handle(b: Block) -> None:
if is_text_block(b):
print(b["text"]) # TextBlock
else:
print(b["name"]) # ToolUseBlock (TypeGuard would leave Block here)TypeGuard[T] | TypeIs[T] | |
|---|---|---|
| True branch | exactly T | input type intersected with T |
| False branch | unchanged | input minus T |
T must be a subtype of the input | no (can convert list[object] to list[str]) | yes |
| Use | changing the "view" of a value | "is this one of the union members?" |
Narrowing is lost when the value could change: attributes (self.client) after
any call, or captured variables in nested functions. Copy to a local first:
client = self.client; if client is None: ....
Self, classmethods and fluent APIs
from typing import Self
class Pipeline:
def __init__(self) -> None:
self.steps: list[str] = []
def add(self, step: str) -> Self: # subclasses keep their own type
self.steps.append(step)
return self
@classmethod
def from_config(cls, steps: list[str]) -> Self:
p = cls()
for s in steps:
p.add(s)
return p
class RagPipeline(Pipeline):
def rerank(self) -> Self:
return self.add("rerank")
RagPipeline().add("retrieve").rerank() # ok: add returns RagPipelineBefore 3.11: from typing_extensions import Self, or a bound TypeVar:
TPipeline = TypeVar("TPipeline", bound="Pipeline") and def add(self: TPipeline, ...) -> TPipeline.
Self is not allowed in static methods or module-level functions.
Never, NoReturn and exhaustiveness
from enum import Enum
from typing import NoReturn, assert_never
def fail(msg: str) -> NoReturn:
raise RuntimeError(msg)
class Finish(Enum):
STOP = "stop"
LENGTH = "length"
TOOL = "tool_calls"
def describe(reason: Finish) -> str:
if reason is Finish.STOP:
return "complete"
elif reason is Finish.LENGTH:
return "truncated"
elif reason is Finish.TOOL:
return "needs tool"
else:
assert_never(reason) # error here if a member is added and not handledNever and NoReturn are the same type; convention: NoReturn for return
annotations, Never elsewhere. Code after a call to a NoReturn function is
treated as unreachable.
TypeVarTuple and Unpack
Variadic generics: a variable number of types, mostly for array shapes and
*args forwarding.
from collections.abc import Callable
def call_later[*Ts](fn: Callable[[*Ts], None], *args: *Ts) -> None:
fn(*args)
def log(level: int, msg: str) -> None: ...
call_later(log, 10, "hi") # ok
call_later(log, "hi", 10) # error: argument types do not match
class Array[*Shape]:
def __init__(self, *shape: *Shape) -> None:
self.shape = shapePre-3.11 spelling: Ts = TypeVarTuple("Ts"), Unpack[Ts] instead of *Ts.
Array libraries have not standardised on TypeVarTuple shapes yet; for tensors
use jaxtyping (see ai-ml-typing.md).
Annotated metadata
Annotated[T, x, y] is just T to the checker; tools read the extra metadata.
from typing import Annotated
from fastapi import Query
from pydantic import BaseModel, Field
class SearchRequest(BaseModel):
query: Annotated[str, Field(min_length=1, max_length=2000)]
k: Annotated[int, Field(ge=1, le=100)] = 10
def search(q: Annotated[str, Query(max_length=200)], k: Annotated[int, Query(ge=1)] = 5) -> list[str]: ...
type Temperature = Annotated[float, Field(ge=0.0, le=2.0)] # reusable constrained typeDecorators for type checkers
from typing import final, override # override: 3.12
from warnings import deprecated # 3.13 (typing_extensions before)
class BaseRetriever:
def retrieve(self, query: str) -> list[str]:
return []
@final
def close(self) -> None: ... # subclasses may not override
class BM25Retriever(BaseRetriever):
@override
def retrieve(self, query: str) -> list[str]: # error if the base method is renamed
return [query]
@deprecated("use embed_batch()")
def embed_one(text: str) -> list[float]: ... # callers get a checker warning@dataclass_transform() (3.11) marks your own decorator, base class or
metaclass as creating dataclass-like __init__s (pydantic, SQLModel and attrs
use it), so checkers synthesise constructors for model classes you define.
Recursive types and JSON
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None # 3.12
import json
def load_json(text: str) -> JSON:
return json.loads(text) # json.loads returns Any; the annotation documents the boundary
def get_path(doc: JSON, *keys: str) -> JSON:
for k in keys:
if not isinstance(doc, dict):
raise KeyError(k)
doc = doc[k]
return docRecursive aliases are supported by mypy and pyright. Prefer
Mapping[str, object] for "some JSON object I only pass along" and parse into
TypedDict/pydantic as soon as you depend on the structure.
Stubs and py.typed
| Need | Do |
|---|---|
| Your package is typed | add an empty py.typed file to the package and include it in the wheel ([tool.setuptools.package-data] mypkg = ["py.typed"], hatch/poetry include it automatically) |
| Third-party package without types | pip install types-<name> (typeshed: types-requests, types-PyYAML, types-redis for old redis, ...) |
| No stubs exist | write stubs/<pkg>/__init__.pyi with only the functions you use; set mypy_path = "stubs" / pyright stubPath = "stubs" |
| Generate a starting stub | stubgen -p <pkg> -o stubs (mypy) or pyright --createstub <pkg> |
| Partial package | py.typed containing partial\n |
| Verify stubs match runtime | python -m mypy.stubtest <pkg> |
A .pyi stub contains signatures only:
# stubs/legacy_vectordb/__init__.pyi
from collections.abc import Sequence
class Collection:
def add(self, ids: Sequence[str], embeddings: Sequence[Sequence[float]]) -> None: ...
def query(self, embedding: Sequence[float], n_results: int = ...) -> dict[str, list[list[str]]]: ...
def connect(url: str, *, timeout: float = ...) -> Collection: ...python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-comprehension-refactorer
Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.
python-exception-handling-designer
Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.
python-idiomatic-code-reviewer
Review Python code for non-idiomatic, unpythonic or risky patterns and rewrite it into clean modern Python: PEP 8 / PEP 20, EAFP vs LBYL, iteration idioms (enumerate, zip, comprehensions, any/all), unpacking, f-strings, pathlib, truthiness, `is None`, mutable defaults, walrus, match statements, collections / itertools / functools, logging instead of print, with-blocks and resource handling. Includes AI-engineering checks: hardcoded API keys, time.sleep or requests inside async def, missing HTTP timeouts, unbounded retry loops, model clients built at import time, prompt string concatenation, sequential awaits, eval() on LLM output. Ships a 46-rule AST reviewer with severities and suggested rewrites.