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:

bash
# 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:

text
['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.

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

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.

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

python
# 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"
bash
mypy --strict generics.py

Output:

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

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

python
# 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
bash
mypy --strict generic_box.py

Output:

text
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).

python
# 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))
bash
mypy --strict protocol_demo.py && python protocol_demo.py

Output:

text
Success: no issues found in 1 source file
True

Use Protocol when you need to type something you don't control — a third-party object whose only contract is "has read() and write()". Use ABCs (from abc) 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.

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

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

python
# 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
bash
mypy --strict self_type.py

Output:

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

python
# 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
bash
mypy --strict annotated_demo.py

Output:

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

python
# 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
bash
mypy --strict typed_dict.py

Output:

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

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

python
# 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)
bash
mypy --strict narrow.py

Output:

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

python
# 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"
bash
mypy --strict checker_tools.py

Output:

text
checker_tools.py:6: note: Revealed type is "builtins.str"
checker_tools.py:9: error: Expression is of type "int", not "str"  [assert-type]

cast is an escape hatch — it does NO runtime conversion. If raw is actually an int, the program will still blow up later. Reach for TypeGuard / isinstance first.

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

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

python
# 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]
bash
mypy --strict overloads.py

Output:

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

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

  1. Forward references — referring to a class inside its own annotations (def f(self) -> "MyClass") requires either a string literal or from __future__ import annotations (3.7+). The latter lazifies all annotations file-wide.
  2. Runtime introspection breaks under PEP 563from __future__ import annotations turns annotations into strings; typing.get_type_hints() evaluates them. Frameworks like pydantic and FastAPI rely on this; mixing eager and lazy evaluation surfaces as NameError at runtime.
  3. Optional[X] vs X | None — semantically identical; stylistic only. Prefer X | None on 3.10+.
  4. List vs listtyping.List is deprecated; use list[int] on 3.9+. Same goes for Dict, Tuple, Set, FrozenSet, Type.
  5. isinstance(x, Union[A, B]) doesn't work — but isinstance(x, A | B) does on 3.10+. For older Pythons use isinstance(x, (A, B)).
  6. Callable[[int], int] not Callable[int, int] — the args are in a list. Easy to miss because it's the only generic in typing with this shape.
  7. TypeVar reuse — declare each TypeVar once at module scope. Reusing the same name inside multiple functions creates a single shared variable; reusing a fresh TypeVar("T") per function is what most people actually mean.
  8. Self only works on methods — outside a class body, use a TypeVar bound to the class instead.
  9. Protocol checks are structural, not nominal — adding @runtime_checkable only checks attribute presence, not signatures. Two methods with the same name but incompatible signatures still pass isinstance.
  10. Annotated metadata isn't enforced — it's free-form. The checker ignores it; runtime tools have to opt in to read it.
  11. cast lies to the checker — it does no runtime conversion. Misusing it produces silent bugs that surface far from the cast site.
  12. Literal only accepts certain types — strings, ints, bytes, bools, enum members, and None. 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.

python
# 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
bash
mypy --strict registry.py

Output:

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

python
# 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
bash
mypy --strict retry.py

Output:

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

python
# 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")
bash
python narrow_json.py

Output:

text
ALICE

When to reach for typing vs pydantic vs dataclasses

NeedTool
Pure type hints; no runtime cost; broad compatibilitytyping (+ mypy)
Plain Python objects with constructors + repr + eq@dataclass
Runtime validation, coercion, JSON I/Opydantic
Describing the shape of a raw dict (e.g. JSON API payload)TypedDict
Describing an interface satisfied by many unrelated classesProtocol
Cross-checked enum-like string setLiteral["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.

toml
[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.

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