TypedDict superpowers
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.
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 requiredfrom 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-onlyfrom 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
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.
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 neededfrom 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.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| Shape | Runtime | Best for |
|---|---|---|
| NamedTuple | Real tuple, real attributes | Records, row data |
| tuple[T, ...] | Real tuple, no names | Homogeneous sequences |
| TypedDict | Plain dict, keys not enforced | JSON / 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)
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.
# 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)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.valuefrom 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 wrapperfrom 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
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.
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)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 Modefrom 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 literalSecurity 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
@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.
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") # bytesdef 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-onlyfrom 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
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.
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).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 unreachableRuntime 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
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.
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:
...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 SpecialBuilderfrom 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 methodRuntime 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.