cheat sheet
typing
Go beyond list[int] and str | None. Covers Protocol, TypeVar bounds, ParamSpec, Concatenate, Self, Annotated, TypedDict with NotRequired/Required, TypeAlias, TypeGuard, TypeIs, cast, assert_type, and reveal_type.
typing — Advanced Type Hints
What it is
The typing module is the standard library home for PEP 484+ type hints — the constructs that go beyond plain int, str, and list annotations and let you describe generics, structural interfaces, narrowing predicates, and metadata-decorated types. The module itself does almost no runtime work; type checkers like mypy, pyright, and pyre are what actually interpret the annotations. Pair this page with mypy (the checker) and pydantic (runtime enforcement); the three together cover the typical Python typing stack.
Install
typing is stdlib — no install. A few extras are useful:
# Stdlib — already there
python -c "import typing; print(typing.__all__[:5])"
# Backports for older Pythons (TypeGuard for <3.10, Self for <3.11, etc.)
pip install typing_extensions
# Run mypy or pyright to actually check your annotations
pip install mypy
pip install pyright
Output:
['Annotated', 'Any', 'Callable', 'ClassVar', 'Concatenate']
Syntax
Type hints attach to function parameters, return values, variables, and class attributes with a colon. They are not enforced at runtime by Python itself — they exist for type checkers and IDEs. Use typing_extensions to backport newer constructs onto older interpreters.
def function(arg: TypeHint, *, kwarg: TypeHint = default) -> ReturnType: ...
var: TypeHint = value
class C:
attr: TypeHint
Output: (none — exits 0 on success)
Built-in vs typing forms
Since Python 3.9, the built-in generics (list[int], dict[str, int], tuple[int, ...]) replaced the older List, Dict, Tuple from typing. Since 3.10 the X | Y union syntax replaced Union[X, Y] and X | None replaced Optional[X]. Modern code uses the built-ins; the typing.List family still works but is deprecated for removal in a far-future Python.
# Modern (3.9+ generics, 3.10+ unions)
def head(xs: list[int]) -> int | None:
return xs[0] if xs else None
# Old style — still valid, but verbose
from typing import List, Optional, Union
def head_old(xs: List[int]) -> Optional[int]:
return xs[0] if xs else None
# Mixed unions
def parse(value: int | str | None) -> bool: ...
Output: (none — exits 0 on success)
typing vs collections.abc
For abstract container types (Iterable, Iterator, Sequence, Mapping, MutableMapping), import from collections.abc, not from typing. The typing aliases are deprecated; collections.abc versions support generic subscription since 3.9.
from collections.abc import Iterable, Mapping, Iterator, Callable
def sum_all(xs: Iterable[int]) -> int:
return sum(xs)
def items(d: Mapping[str, int]) -> Iterator[tuple[str, int]]:
return iter(d.items())
# Callable from collections.abc, NOT typing.Callable
Handler = Callable[[str, int], bool]
Output: (none — exits 0 on success)
TypeVar — generic type variables
A TypeVar is a placeholder that the type checker fills in from usage. Bind it to a value or container to write functions and classes that preserve element type without losing it to Any. Pass bound= to constrain the variable to subclasses of a base; pass several positional arguments to constrain it to one of a fixed set.
# generics.py
from typing import TypeVar
T = TypeVar("T") # unconstrained
N = TypeVar("N", int, float) # value-constrained
S = TypeVar("S", bound="Shape") # bound: must be a Shape
def first(xs: list[T]) -> T:
return xs[0]
def add(a: N, b: N) -> N:
return a + b
class Shape: ...
class Circle(Shape): ...
def clone(s: S) -> S: # return type matches input
return s
reveal_type(first([1, 2, 3])) # Revealed type is "int"
reveal_type(first(["a", "b"])) # Revealed type is "str"
reveal_type(clone(Circle())) # Revealed type is "Circle"
mypy --strict generics.py
Output:
generics.py:18: note: Revealed type is "builtins.int"
generics.py:19: note: Revealed type is "builtins.str"
generics.py:20: note: Revealed type is "generics.Circle"
Success: no issues found in 1 source file
Variance: covariant and contravariant
A TypeVar is invariant by default — list[Dog] is not assignable to list[Animal] even when Dog is an Animal, because mutation could insert a non-Dog. Mark a TypeVar covariant=True for read-only producers and contravariant=True for write-only consumers. The convention is to suffix the name with _co and _contra.
from typing import TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
class Producer(Generic[T_co]):
def get(self) -> T_co: ... # only produces T_co — safe to be covariant
class Consumer(Generic[T_contra]):
def put(self, item: T_contra) -> None: ... # only consumes T_contra
Output: (none — exits 0 on success)
Generic — typed containers
Generic[T] is the base class for user-defined generic types. It lets the checker bind the type variable from a constructor call or from explicit subscription. PEP 695 (3.12+) ships a more concise syntax with class Box[T]: — both work, but PEP 695 is preferred on 3.12+ for new code.
# generic_box.py
from typing import Generic, TypeVar
T = TypeVar("T")
# Classic (PEP 484) — works on every supported Python
class Box(Generic[T]):
def __init__(self, item: T) -> None:
self.item = item
def get(self) -> T:
return self.item
# PEP 695 (3.12+) — preferred for new code
class Box695[T]:
def __init__(self, item: T) -> None:
self.item = item
def get(self) -> T:
return self.item
reveal_type(Box(42).get()) # int
reveal_type(Box695("hi").get()) # str
mypy --strict generic_box.py
Output:
generic_box.py:17: note: Revealed type is "builtins.int"
generic_box.py:18: note: Revealed type is "builtins.str"
Success: no issues found in 1 source file
Protocol — structural subtyping ("duck typing with types")
A Protocol describes what an object can do, not what it is. Any class that exposes matching attributes and methods is implicitly compatible — no inheritance required. This is the typed counterpart of Python's traditional duck typing. Decorate with @runtime_checkable to make isinstance(x, MyProto) work at runtime (it only checks attribute presence, not signatures).
# protocol_demo.py
from typing import Protocol, runtime_checkable
class SupportsLen(Protocol):
def __len__(self) -> int: ...
def cardinality(x: SupportsLen) -> int:
return len(x)
# Anything with __len__ satisfies the protocol — no inheritance
cardinality([1, 2, 3])
cardinality("abc")
cardinality({"a": 1})
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class File:
def close(self) -> None: ...
print(isinstance(File(), Closeable))
mypy --strict protocol_demo.py && python protocol_demo.py
Output:
Success: no issues found in 1 source file
True
Use
Protocolwhen you need to type something you don't control — a third-party object whose only contract is "hasread()andwrite()". Use ABCs (fromabc) when you control the hierarchy and want runtime enforcement of method overrides.
ParamSpec + Concatenate — typed decorators
ParamSpec (3.10+) is to function signatures what TypeVar is to types — a placeholder for "the entire parameter list of some callable". Combined with Concatenate, it lets you write decorators that prepend or strip arguments while preserving the wrapped function's signature for the type checker. This was the missing piece that made @functools.wraps actually type-safe.
# decorators.py
from typing import Callable, Concatenate, ParamSpec, TypeVar
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def logged(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@logged
def add(a: int, b: int) -> int:
return a + b
reveal_type(add) # def (a: int, b: int) -> int -- signature preserved
add(1, 2)
add("oops", 2) # error: arg 1 has incompatible type "str"; expected "int"
Concatenate — prepending a parameter
A Concatenate[Self, P] says "this callable takes Self first, then whatever P describes". Use it for decorators that inject context (a session, a connection) ahead of the user's arguments.
def with_session(fn: Callable[Concatenate[Session, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
with Session() as s:
return fn(s, *args, **kwargs)
return wrapper
@with_session
def get_user(session: Session, user_id: int) -> User: ...
reveal_type(get_user) # def (user_id: int) -> User -- session injected
Output: (none — exits 0 on success)
Self — current class as a return type
Self (3.11+, in typing; backported via typing_extensions) names "the type of the class this method was called on". It is the right way to annotate fluent builders and classmethod alternate constructors so subclasses get the subclass type back, not the base class.
# self_type.py
from typing import Self
class Query:
def __init__(self) -> None:
self.filters: list[str] = []
def where(self, clause: str) -> Self:
self.filters.append(clause)
return self
@classmethod
def from_dict(cls, data: dict[str, str]) -> Self:
q = cls()
for k, v in data.items():
q.where(f"{k}={v}")
return q
class UserQuery(Query):
def active(self) -> Self:
return self.where("active=true")
q: UserQuery = UserQuery.from_dict({"id": "1"}).active()
reveal_type(q) # UserQuery — not Query
mypy --strict self_type.py
Output:
self_type.py:24: note: Revealed type is "self_type.UserQuery"
Success: no issues found in 1 source file
Annotated — metadata-decorated types
Annotated[T, ...meta] (3.9+) attaches arbitrary metadata to a type. The type checker sees only T; runtime tools (FastAPI, pydantic, typer, msgspec) read the metadata to drive validation, dependency injection, or CLI options. This is the typing pattern modern Python frameworks have converged on.
# annotated_demo.py
from typing import Annotated
from dataclasses import dataclass
# Reusable, semantic alias
Email = Annotated[str, "must contain @"]
PositiveInt = Annotated[int, "must be > 0"]
def send(addr: Email, *, retries: PositiveInt = 3) -> None: ...
# FastAPI / pydantic / typer all key off Annotated metadata
@dataclass
class Config:
timeout: Annotated[float, "seconds"] = 5.0
workers: Annotated[int, "1..16"] = 4
mypy --strict annotated_demo.py
Output:
Success: no issues found in 1 source file
TypedDict — typed dict literals
TypedDict describes the shape of a dict whose keys are known at design time — JSON payloads, API responses, config dictionaries. Unlike a regular dict[str, Any], the checker knows each key's value type. Mark individual keys as NotRequired (3.11+) or Required when the class default is the opposite, or set total=False to make every key optional unless wrapped in Required.
# typed_dict.py
from typing import TypedDict, NotRequired, Required
class UserDict(TypedDict):
id: int
name: str
email: NotRequired[str] # may be absent
class PartialUser(TypedDict, total=False):
id: Required[int] # this one is mandatory
name: str # all others are optional
email: str
def greet(u: UserDict) -> str:
return f"hi {u['name']}"
greet({"id": 1, "name": "Alice"}) # ok — email is NotRequired
greet({"id": 1, "name": "Alice", "email": "alice@example.com"})
# Errors caught at check time:
greet({"id": "1", "name": "Alice"}) # id should be int
greet({"name": "Alice"}) # missing id
greet({"id": 1, "name": "Alice", "extra": 1}) # unknown key
mypy --strict typed_dict.py
Output:
typed_dict.py:19: error: Dict entry 0 has incompatible type "str": "str"; expected "str": "int" [dict-item]
typed_dict.py:20: error: Missing key "id" for TypedDict "UserDict" [typeddict-item]
typed_dict.py:21: error: Extra key "extra" for TypedDict "UserDict" [typeddict-unknown-key]
TypeAlias — naming a type explicitly
A TypeAlias annotation makes it unambiguous that a name is a type, not a value, which is otherwise easy to confuse the checker about (especially for forward references or complex unions). In 3.12+ the PEP 695 type X = … statement is the preferred, runtime-aware form.
# aliases.py
from typing import TypeAlias
# PEP 484 style — explicit annotation
JSON: TypeAlias = "dict[str, JSON] | list[JSON] | str | int | float | bool | None"
# PEP 695 style (3.12+) — runtime-aware type alias
type Vector = list[float]
type Matrix = list[Vector]
def normalize(v: Vector) -> Vector: ...
def transpose(m: Matrix) -> Matrix: ...
Output: (none — exits 0 on success)
TypeGuard and TypeIs — narrowing predicates
A TypeGuard[T] (3.10+) is a function whose True return value tells the type checker "the argument is a T". This is how you teach the checker about runtime checks it can't infer on its own — e.g. "this list contains only strings". TypeIs[T] (3.13+) is a stricter, bidirectional version: a False return also narrows the input to "not T". Prefer TypeIs on 3.13+; fall back to TypeGuard elsewhere.
# narrow.py
from typing import TypeGuard, TypeIs # TypeIs needs Python 3.13+ or typing_extensions
def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def stringify(items: list[object]) -> str:
if is_str_list(items):
return ", ".join(items) # items: list[str] here
return ", ".join(map(str, items))
# Python 3.13+: TypeIs narrows both branches
def is_int(x: object) -> TypeIs[int]:
return isinstance(x, int)
def double(x: int | str) -> int | str:
if is_int(x):
return x * 2 # x: int
return x * 2 # x: str (TypeIs narrowed the False branch too)
mypy --strict narrow.py
Output:
Success: no issues found in 1 source file
cast, assert_type, reveal_type — checker tools
These three help you guide and verify the type checker. cast(T, x) forces the checker to treat x as a T (no runtime check — be conservative). assert_type(x, T) is a compile-time assertion that the inferred type is exactly T; it raises if not. reveal_type(x) is not really a function — it's a directive the checker recognises to print the inferred type, then strips at runtime.
# checker_tools.py
from typing import cast, assert_type, reveal_type
raw: object = "hello"
s = cast(str, raw) # tells checker to trust you; no runtime cost
reveal_type(s) # Revealed type: builtins.str
x = 42
assert_type(x, int) # passes
assert_type(x, str) # mypy error: Expression is of type "int", not "str"
mypy --strict checker_tools.py
Output:
checker_tools.py:6: note: Revealed type is "builtins.str"
checker_tools.py:9: error: Expression is of type "int", not "str" [assert-type]
castis an escape hatch — it does NO runtime conversion. Ifrawis actually anint, the program will still blow up later. Reach forTypeGuard/isinstancefirst.
Final, Literal, ClassVar
Final marks a name as non-reassignable. Literal[...] constrains a value to one of a fixed set of singletons. ClassVar marks a class attribute that is not a per-instance field (relevant when paired with @dataclass).
# specials.py
from typing import Final, Literal, ClassVar
from dataclasses import dataclass
MAX_RETRIES: Final = 5
MAX_RETRIES = 10 # mypy error: cannot assign to Final
def set_level(level: Literal["debug", "info", "warning", "error"]) -> None: ...
set_level("verbose") # error — not one of the literals
@dataclass
class Counter:
value: int = 0
DEFAULT_STEP: ClassVar[int] = 1 # shared by all instances, not a field
Output: (none — exits 0 on success)
overload — multiple signatures, one implementation
@overload declares alternate signatures for a function whose return type depends on its arguments — the type checker picks the matching overload, but only the final (un-decorated) implementation runs at runtime. This is the cleanest way to type stdlib-like helpers (json.loads, pathlib.Path) where one argument flips the return shape.
# overloads.py
from typing import overload
@overload
def parse(s: str, *, as_list: Literal[True]) -> list[int]: ...
@overload
def parse(s: str, *, as_list: Literal[False] = ...) -> int: ...
def parse(s: str, *, as_list: bool = False) -> int | list[int]:
if as_list:
return [int(p) for p in s.split(",")]
return int(s)
reveal_type(parse("1")) # int
reveal_type(parse("1,2,3", as_list=True)) # list[int]
mypy --strict overloads.py
Output:
overloads.py:13: note: Revealed type is "builtins.int"
overloads.py:14: note: Revealed type is "builtins.list[builtins.int]"
Success: no issues found in 1 source file
NewType — opaque type aliases
NewType creates a zero-cost wrapper that the checker treats as a distinct type but Python sees as the underlying type. Use it for "string-ly typed" identifiers — UserId, Email, TenantId — to stop accidentally passing a UserId where a TenantId was expected.
# newtype_demo.py
from typing import NewType
UserId = NewType("UserId", int)
TenantId = NewType("TenantId", int)
def get_user(uid: UserId) -> str: ...
def get_tenant(tid: TenantId) -> str: ...
uid = UserId(42)
tid = TenantId(7)
get_user(uid) # ok
get_user(tid) # error: TenantId is not assignable to UserId
get_user(42) # error: int is not assignable to UserId — must wrap
Output: (none — exits 0 on success)
Common pitfalls
- Forward references — referring to a class inside its own annotations (
def f(self) -> "MyClass") requires either a string literal orfrom __future__ import annotations(3.7+). The latter lazifies all annotations file-wide. - Runtime introspection breaks under PEP 563 —
from __future__ import annotationsturns annotations into strings;typing.get_type_hints()evaluates them. Frameworks like pydantic and FastAPI rely on this; mixing eager and lazy evaluation surfaces asNameErrorat runtime. Optional[X]vsX | None— semantically identical; stylistic only. PreferX | Noneon 3.10+.Listvslist—typing.Listis deprecated; uselist[int]on 3.9+. Same goes forDict,Tuple,Set,FrozenSet,Type.isinstance(x, Union[A, B])doesn't work — butisinstance(x, A | B)does on 3.10+. For older Pythons useisinstance(x, (A, B)).Callable[[int], int]notCallable[int, int]— the args are in a list. Easy to miss because it's the only generic intypingwith this shape.TypeVarreuse — declare eachTypeVaronce at module scope. Reusing the same name inside multiple functions creates a single shared variable; reusing a freshTypeVar("T")per function is what most people actually mean.Selfonly works on methods — outside a class body, use aTypeVarbound to the class instead.Protocolchecks are structural, not nominal — adding@runtime_checkableonly checks attribute presence, not signatures. Two methods with the same name but incompatible signatures still passisinstance.Annotatedmetadata isn't enforced — it's free-form. The checker ignores it; runtime tools have to opt in to read it.castlies to the checker — it does no runtime conversion. Misusing it produces silent bugs that surface far from the cast site.Literalonly accepts certain types — strings, ints, bytes, bools, enum members, andNone. Not floats, not arbitrary objects.
Real-world recipes
A typed registry using Protocol + TypeVar
You want a registry of "things that have a .run(ctx) method", parameterised by the result type. Protocol describes the interface and TypeVar keeps the result type associated with each entry.
# registry.py
from typing import Protocol, TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)
class Task(Protocol, Generic[T_co]):
def run(self, ctx: dict[str, object]) -> T_co: ...
class FetchUser:
def run(self, ctx: dict[str, object]) -> dict[str, str]:
return {"name": "Alice"}
class CountRows:
def run(self, ctx: dict[str, object]) -> int:
return 42
registry: dict[str, Task[object]] = {
"fetch_user": FetchUser(),
"count_rows": CountRows(),
}
result = registry["fetch_user"].run({})
reveal_type(result) # object — broad, but safe
mypy --strict registry.py
Output:
registry.py:21: note: Revealed type is "builtins.object"
Success: no issues found in 1 source file
Type-safe wraps with ParamSpec
A retry decorator should preserve the wrapped function's signature so callers get IDE autocomplete and the checker validates argument types. ParamSpec + TypeVar is the canonical pattern.
# retry.py
from typing import Callable, ParamSpec, TypeVar
from functools import wraps
import time
P = ParamSpec("P")
R = TypeVar("R")
def retry(times: int = 3, delay: float = 1.0) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(times):
try:
return fn(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
time.sleep(delay)
raise RuntimeError("unreachable")
return wrapper
return decorator
@retry(times=5)
def fetch(url: str, timeout: float = 10.0) -> bytes: ...
reveal_type(fetch) # def (url: str, timeout: float = ...) -> bytes
mypy --strict retry.py
Output:
retry.py:26: note: Revealed type is "def (url: builtins.str, timeout: builtins.float =) -> builtins.bytes"
Success: no issues found in 1 source file
JSON parsing with TypedDict + TypeGuard
You receive untyped JSON and want to narrow it to a TypedDict before using it. isinstance doesn't work on TypedDict; write a TypeGuard that checks the keys yourself.
# narrow_json.py
import json
from typing import TypedDict, TypeGuard
class UserPayload(TypedDict):
id: int
name: str
email: str
def is_user_payload(obj: object) -> TypeGuard[UserPayload]:
return (
isinstance(obj, dict)
and isinstance(obj.get("id"), int)
and isinstance(obj.get("name"), str)
and isinstance(obj.get("email"), str)
)
raw = json.loads('{"id": 1, "name": "Alice", "email": "alice@example.com"}')
if is_user_payload(raw):
print(raw["name"].upper()) # checker knows raw is UserPayload here
else:
print("invalid payload")
python narrow_json.py
Output:
ALICE
When to reach for typing vs pydantic vs dataclasses
| Need | Tool |
|---|---|
| Pure type hints; no runtime cost; broad compatibility | typing (+ mypy) |
| Plain Python objects with constructors + repr + eq | @dataclass |
| Runtime validation, coercion, JSON I/O | pydantic |
Describing the shape of a raw dict (e.g. JSON API payload) | TypedDict |
| Describing an interface satisfied by many unrelated classes | Protocol |
| Cross-checked enum-like string set | Literal["a", "b", "c"] |
| One ID type that's "an int but not interchangeable" | NewType |
Strict mypy config to surface untyped code
A pyproject.toml block that turns mypy into a hard wall: every function must be annotated, every implicit Any is an error, every untyped third-party import is flagged. Adopt incrementally with [[tool.mypy.overrides]] per-module relaxations.
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
warn_redundant_casts = true
disallow_any_generics = true
no_implicit_reexport = true
[[tool.mypy.overrides]]
module = ["legacy.*", "third_party_untyped.*"]
ignore_missing_imports = true
disallow_untyped_defs = false
Output: (none — exits 0 on success)
Backport modern constructs to old Python
Most typing additions ship in typing_extensions first and land in stdlib one or two releases later. Import from typing_extensions to write code that runs on the oldest Python you support but uses the newest constructs.
# compat.py
import sys
if sys.version_info >= (3, 11):
from typing import Self, NotRequired, Required, assert_type
else:
from typing_extensions import Self, NotRequired, Required, assert_type
# TypeIs lands in stdlib in 3.13
if sys.version_info >= (3, 13):
from typing import TypeIs
else:
from typing_extensions import TypeIs
Output: (none — exits 0 on success)