Advanced Python typing reference

Python Typing Cheat Sheet

Modern typing for Python 3.13 - TypedDict, structural subtyping, PEP 695 generics, narrowing guards, overloads, and the line between what a static checker enforces and what actually runs.

Badge legend

3.13Minimum Python version
PEP 692Relevant specification
mypy / pyrightStatic checkers that support it
staticChecker-only, erased at runtime
runtimeHas real runtime behavior
securitySecurity-relevant note

Every code block has a copy button in its top-right corner.

TypedDict superpowers

3.11+PEP 692PEP 655PEP 705mypy / pyright

A TypedDict describes a dict with a fixed set of keys and their types. By default every key is required (total=True);total=False makes every key optional. Use Required and NotRequired to mix required and optional keys, ReadOnly to forbid reassignment, and Unpack to type **kwargs as a TypedDict.

typeddict_total.py
from typing import TypedDict

# total=True (default): every key is required.
class User(TypedDict):
    id: int
    name: str

ok: User = {"id": 1, "name": "Ada"}
# bad: User = {"id": 1}          # error: missing "name"

# total=False: every key is optional.
class Profile(TypedDict, total=False):
    bio: str
    avatar: str

empty: Profile = {}               # ok - nothing required
typeddict_required_readonly.py
from typing import NotRequired, ReadOnly, Required, TypedDict

class Config(TypedDict):
    host: str
    port: Required[int]            # always required
    timeout: NotRequired[float]    # optional
    debug: ReadOnly[bool]          # cannot be reassigned

cfg: Config = {"host": "localhost", "port": 8080}
cfg["timeout"] = 5.0               # ok - NotRequired key can be set
cfg["debug"] = True                # error: ReadOnly key is read-only
typeddict_unpack_kwargs.py
from typing import NotRequired, TypedDict, Unpack

class GreetOptions(TypedDict):
    greeting: str
    punctuation: NotRequired[str]

def greet(**opts: Unpack[GreetOptions]) -> str:
    return f"{opts['greeting']}{opts.get('punctuation', '!')}"

greet(greeting="Hello")                 # ok
greet(greeting="Hi", punctuation="?")   # ok
greet(punctuation="?")                   # error: missing "greeting"

Runtime vs static checker

A TypedDict is an ordinary dict at runtime - isinstance(cfg, dict) is True. Requiredness,ReadOnly, and Unpack are erased and only guide the checker. ReadOnly is not enforced at runtime; nothing stops cfg["debug"] = True from running.

Structural subtyping & data shapes

3.8+mypy / pyright

A Protocol matches any object with the right shape - no inheritance needed. An ABC is nominal: a class must explicitly inherit to be accepted. @runtime_checkable lets isinstance work on a Protocol, but only checks that members exist, never their types.

protocol_vs_abc.py
from abc import ABC, abstractmethod
from typing import Protocol

# ABC - nominal: a class must explicitly inherit.
class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

class Square(Shape):               # explicit inheritance required
    def __init__(self, side: float) -> None:
        self.side = side
    def area(self) -> float:
        return self.side * self.side

# Protocol - structural: any class with the right shape matches.
class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self) -> None:
        print("circle")

def render(obj: Drawable) -> None:
    obj.draw()

render(Circle())                   # ok - no inheritance needed
runtime_checkable.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class HasName(Protocol):
    name: str

class Person:
    name = "Ada"

print(isinstance(Person(), HasName))  # True - only checks .name EXISTS
# It does NOT verify that .name is a str at runtime.
namedtuple_vs_tuple_vs_typeddict.py
from typing import NamedTuple, TypedDict

# NamedTuple - fixed, named, immutable fields. Great for records.
class Point(NamedTuple):
    x: float
    y: float

# tuple[T, ...] - a variable-length homogeneous tuple.
coords: tuple[float, ...] = (1.0, 2.0, 3.0)

# TypedDict - a dict with named keys. Ideal for JSON-shaped data.
class PointDict(TypedDict):
    x: float
    y: float
ShapeRuntimeBest for
NamedTupleReal tuple, real attributesRecords, row data
tuple[T, ...]Real tuple, no namesHomogeneous sequences
TypedDictPlain dict, keys not enforcedJSON / config payloads

Runtime vs static checker

Protocol matching is static. @runtime_checkable adds a real isinstance check, but it only verifies member presence - not types or signatures. ABCs are enforced at runtime through explicit inheritance and abstractmethod.

Modern generics (PEP 695)

3.12+PEP 695mypy / pyright

PEP 695 (Python 3.12+) removes most TypeVar boilerplate. Use the type statement for aliases, native square-bracket syntax for functions and classes, ParamSpec / Concatenate for decorators, and TypeVarTuple for variadics.

type_alias.py
# PEP 695 - no TypeVar boilerplate needed.
type Point[T] = tuple[T, T]
type IntPoint = Point[int]          # = tuple[int, int]

def midpoint(a: IntPoint, b: IntPoint) -> IntPoint:
    return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
native_generics.py
def first[T](items: list[T]) -> T:
    return items[0]

class Box[T]:
    def __init__(self, value: T) -> None:
        self.value = value
    def get(self) -> T:
        return self.value
paramspec_concatenate.py
from collections.abc import Callable
from typing import Concatenate, ParamSpec

P = ParamSpec("P")

# ParamSpec preserves the wrapped callable's signature.
def logged[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

# Concatenate prepends fixed parameters to a signature.
def inject[**P, R](
    fn: Callable[Concatenate[str, P], R],
) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn("logger", *args, **kwargs)
    return wrapper
typevartuple.py
from typing import TypeVarTuple

Ts = TypeVarTuple("Ts")

def prepend(first: int, *rest: *Ts) -> tuple[int, *Ts]:
    return (first, *rest)

result = prepend(1, "a", 2.5)
# result is inferred as tuple[int, str, float]

Runtime vs static checker

The type statement and native generic syntax are largely erased at runtime. The checker expands aliases and tracks type variables; at runtime there are no TypeVar objects to inspect. ParamSpec and TypeVarTuple are static-only.

Narrowing & guards

3.13PEP 742PEP 675security

TypeGuard[T] narrows the checked expression to T. TypeIs[T] (PEP 742) narrows the original value, so the narrowed type is kept after the guard.Literal restricts a value to a fixed set, and LiteralString rejects non-literal strings at the checker - a real defense against SQL injection.

typeis_vs_typeguard.py
from typing import TypeGuard, TypeIs

# TypeGuard narrows the checked expression only.
def is_str_list(x: object) -> TypeGuard[list[str]]:
    return isinstance(x, list) and all(isinstance(i, str) for i in x)

def handle(x: object) -> None:
    if is_str_list(x):
        x.append("ok")      # x is list[str] here

# TypeIs narrows the ORIGINAL value, keeping the narrowed type.
def is_positive(x: int | float) -> TypeIs[int]:
    return isinstance(x, int) and x > 0

def show(x: int | float) -> None:
    if is_positive(x):
        reveal_type(x)      # int (not just int | float)
literal.py
from typing import Literal

Mode = Literal["read", "write", "append"]

def open_file(path: str, mode: Mode) -> None:
    ...

open_file("a.txt", "read")     # ok
open_file("a.txt", "delete")   # error: not a valid Mode
literalstring_sql.py
from typing import LiteralString

def query(sql: LiteralString) -> None:
    # Runs the statement (illustrative - use a real driver).
    ...

def search(name: str) -> None:
    # error: name is not a LiteralString - blocks injection statically.
    query(f"SELECT * FROM users WHERE name = {name}")
    query("SELECT * FROM users")   # ok - a literal

Security note

LiteralString is a static guard: it rejects non-literal strings at check time. It does not validate at runtime. For real safety, always use parameterized queries (placeholders + bound arguments), never string interpolation.

Runtime vs static checker

TypeIs, TypeGuard, Literal, and LiteralString are erased at runtime. The functions you write are real, but the narrowing they promise is only honored by the checker. If a guard returns the wrong answer, nothing at runtime corrects it.

Overloads & call signatures

3.13PEP 484mypy / pyright

@overload lets the checker pick a return type from the arguments - often combined with Literal. The / and * markers make parameters positional-only and keyword-only. The = ... pattern marks an optional parameter whose real default is unspecified.

overload_literal.py
from typing import Literal, overload

@overload
def parse(value: str) -> dict[str, object]: ...
@overload
def parse(value: bytes) -> bytes: ...
def parse(value: str | bytes) -> dict[str, object] | bytes:
    if isinstance(value, bytes):
        return value
    return {}

parsed = parse("x")    # dict[str, object]
raw = parse(b"x")      # bytes
positional_keyword_only.py
def draw(
    x: float,
    y: float,
    /,                 # positional-only
    color: str = "black",
    *,
    width: int = 1,    # keyword-only
) -> None:
    ...

draw(1.0, 2.0, "red", width=2)   # ok
draw(1.0, 2.0, color="red")      # ok - color is positional-or-keyword
# draw(x=1.0, y=2.0)             # error: x, y are positional-only
# draw(1.0, 2.0, "red", 2)       # error: width is keyword-only
unspecified_default.py
from typing import Any

def configure(
    host: str,
    port: int = ...,
    retries: int = ...,
) -> None:
    ...

# '= ...' marks the parameter optional while leaving the real default
# unspecified - the checker treats it as "optional, default unknown".

Runtime vs static checker

Only the final implementation of an overloaded function runs; the @overload stubs are erased and exist purely for static analysis. The / and * markers are real runtime constraints, but = ... leaves the default as literal Ellipsis at runtime.

Inspection & assertions

3.13mypy / pyright

cast(T, x) overrides the checker's inferred type - it is a runtime no-op that returns x unchanged. assert_type(x, T) verifies the checker's inference and also performs a real runtime check. Never, NoReturn, and assert_never handle unreachable code and match exhaustiveness.

cast_vs_assert_type.py
from typing import assert_type, cast

value: object = "hello"

s = cast(str, value)   # tell the checker "trust me, it's a str"
# cast() is a runtime no-op: it returns value unchanged.

assert_type(s, str)    # verify the checker's inferred type is str
# assert_type ALSO does a real runtime isinstance check (raises TypeError).
never_noreturn.py
from typing import Never, NoReturn, assert_never

def fail() -> NoReturn:
    raise RuntimeError("this never returns")

def impossible(x: Never) -> None:
    ...

def describe(value: int | str) -> str:
    match value:
        case int():
            return f"int {value}"
        case str():
            return value
        case _:
            assert_never(value)  # checker verifies this branch is unreachable

Runtime vs static checker

cast does nothing at runtime - it is purely a static override. assert_type does a real isinstance check and raises TypeError on mismatch. assert_never raises AssertionError if reached (a non-exhaustive match). Never and NoReturn are static-only.

Static guards & class limits

3.13mypy / pyright

TYPE_CHECKING guards imports that only the checker needs, avoiding import cycles. Self preserves the subclass type through fluent chaining. Final and @final forbid reassignment and overriding - but only for the checker.

type_checking.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    # Imported ONLY for the checker - never executed at runtime.
    from database import Connection

def connect(conn: "Connection") -> None:
    ...
self_builder.py
from typing import Self

class Builder:
    def __init__(self) -> None:
        self._parts: list[str] = []

    def add(self, part: str) -> Self:
        self._parts.append(part)
        return self

    def build(self) -> str:
        return "".join(self._parts)

class SpecialBuilder(Builder):
    def special(self) -> Self:
        return self

# Self preserves the subclass type through chaining:
SpecialBuilder().add("a").special()  # still SpecialBuilder
final.py
from typing import Final, final

MAX_RETRIES: Final = 3

class Base:
    @final
    def locked(self) -> None: ...

class Child(Base):
    def locked(self) -> None: ...  # error: cannot override a @final method

Runtime vs static checker

TYPE_CHECKING is a real constant: False at runtime, True for the checker. Self, Final, and @final are static-only - at runtime you can still reassign MAX_RETRIES or override locked. They are contracts for tools and humans, not runtime guards.