Advanced Python Typing: TypedDict, Literal, Protocol, Generics, and Callable Types
A dict payload that passes every test until someone renames a key. A callback whose signature drifts by one argument. A duck-typed object that satisfies…

Key topics
A dict payload that passes every test until someone renames a key. A callback whose signature drifts by one argument. A duck-typed object that satisfies the checker and explodes at runtime. These are not syntax problems. They are boundary problems — and boundary problems are where systems break.
Annotations document; they do not enforce. That distinction is the whole game. Every construct in this article moves a runtime assumption into a checkable declaration, and each one has a specific place where it stops helping. The goal is not to annotate everything. The goal is to spend typing effort where a wrong assumption is silent and expensive.
Why Boundary Types Beat Inline Types
Internal code is forgiving. A function calls another function in the same module, the shapes match, the tests pass. The seam between components, providers, or teams is where the assumptions leak.
Consider a payload that flows from an API handler into a model client. The handler builds a dict. The client reads payload["model"] and payload["messages"]. Nothing is wrong until a refactor renames model to model_id. The handler still builds a dict. The client still reads a key. The tests that mock the client never touch the real key. The failure surfaces in production, at the seam, where the two components disagree about a contract neither one declared.
That is the decision axis for everything that follows: the cost of a wrong assumption at the boundary versus the cost of maintaining the annotation. A silent key rename, a wrong callback arity, an incompatible provider response — these are expensive because they are invisible until runtime. An annotation that catches them at check time is cheap by comparison.
Annotate public APIs, mixed collections, and callable signatures. Skip annotations where inference already carries the type and no external caller depends on it.
The recurring theme: static checkers verify declarations, not runtime state. A passing type check is not a runtime guarantee. Every construct below has a gap between what the checker sees and what the interpreter does, and knowing where that gap sits is the difference between using the tool and trusting it blindly.
TypedDict: Naming the Shape of a Dict Payload
TypedDict declares a fixed key set with per-key value types. At runtime, instances are plain dicts. Nothing is enforced when the data arrives.
from typing import TypedDict
class ChatRequest(TypedDict):
model: str
messages: list[dict[str, str]]
temperature: float
The checker will reject ChatRequest(model="x", messages=[], temperature="hot"). It will also reject a missing key. But if the data comes from json.loads(request.body), the annotation is a promise about data you never validated. A checker-clean TypedDict can still receive a missing key at runtime.
Required and optional keys are where this gets practical. total=False makes every key optional. Required and NotRequired (Python 3.11+) let you mix:
from typing import TypedDict, NotRequired, Required
class ChatRequest(TypedDict):
model: Required[str]
messages: Required[list[dict[str, str]]]
temperature: NotRequired[float]
metadata: NotRequired[dict[str, str]]
Inheritance extends a base shape:
class StreamingRequest(ChatRequest):
stream: bool
Functional syntax handles keys that are not valid identifiers — keywords, hyphens, or names that would be mangled:
ExternalPayload = TypedDict("ExternalPayload", {
"in": int,
"x-y": str,
"content-type": str,
})
That form is useful when you are mapping external JSON fields that do not follow Python naming conventions.
The failure mode is consistent: data crossing a network or JSON boundary is not validated by the annotation. The decision rule follows from that. Use TypedDict for dict-shaped data you do not control the construction of. Use a dataclass when you own construction and want runtime structure — a dataclass actually builds an object with attributes, and __init__ enforces arity. Where TypedDict stops helping: nested or deeply dynamic payloads, and any case where you need runtime validation. Pair it with an explicit validation step — a schema check, a Pydantic model, or a hand-written guard — at the point where external data enters.
Knowledge check
Check your understanding
Answer this question before you continue.
Literal and Final: Narrowing Values, Not Just Types
Literal pins a parameter or field to a closed set of values. This is precision that types alone cannot express.
from typing import Literal
Mode = Literal["chat", "completion", "embedding"]
def call_provider(mode: Mode, payload: dict) -> dict:
...
A bare str accepts "chat", "completion", "embedding", "chta", and "". Literal rejects everything outside the set at check time. That matters for mode flags, provider names, role strings, and status codes — anywhere a wrong string silently changes behavior instead of raising an error.
Combining literals into named aliases keeps call sites readable and makes exhaustive handling visible:
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
StatusCode = Literal[200, 201, 400, 401, 403, 404, 500]
Final marks a name or attribute as not reassigned. It documents intent and lets the checker flag accidental rebinding:
from typing import Final
DEFAULT_MODEL: Final = "gpt-4o"
The failure mode for both: they are static constraints only. A value read from config, an environment variable, or an API response is not narrowed at runtime. mode = os.environ["MODE"] produces a str, not a Mode. The checker will accept the assignment only if you cast or validate, and the cast is a lie unless you actually check the value.
Reach for
Literalwhen a wrong string silently changes behavior. Skip it when the value set is genuinely open or externally defined.
Knowledge check
Check your understanding
Answer this question before you continue.
Protocol: Structural Contracts Without Inheritance
Protocol formalizes duck typing for the checker. Conformance is determined by member presence and compatibility, not by declared inheritance.
from typing import Protocol
class ModelClient(Protocol):
def complete(self, prompt: str) -> str: ...
def embed(self, text: str) -> list[float]: ...
def run_pipeline(client: ModelClient, prompt: str) -> str:
return client.complete(prompt)
Any object with complete and embed satisfies ModelClient. The caller does not need to inherit from your base class. This is the design principle: specify the minimum interface a function needs, so callers can satisfy it without adopting your hierarchy.
Bounds vs. Generic Protocols
A TypeVar bound constrains a type parameter to "anything with this method." That is different from a generic protocol, which preserves a type relationship across an interface. The distinction matters when you need the input and output types to stay linked.
from typing import Protocol, TypeVar
class HasClose(Protocol):
def close(self) -> None: ...
T = TypeVar("T", bound=HasClose)
def cleanup(resource: T) -> T:
resource.close()
return resource
Here T is bound to HasClose — the function accepts any type with a close method and returns the same type. The type relationship is preserved by the TypeVar, not by the protocol.
A generic protocol is different. It carries a type parameter that flows through the interface itself:
from typing import Protocol, TypeVar
T_co = TypeVar("T_co", covariant=True)
class Source(Protocol[T_co]):
def read(self) -> T_co: ...
def drain(source: Source[str]) -> list[str]:
return [source.read()]
Source[str] and Source[bytes] are distinct types, and the protocol preserves which one you passed. Use a bound when you only need "has this method." Use a generic protocol when the type flowing through the interface must survive.
runtime_checkable Is a Presence Check, Not a Signature Check
runtime_checkable enables isinstance checks, but only verifies member presence, not signatures:
from typing import runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class NotReallyCloseable:
def close(self, force: bool) -> None: ...
isinstance(NotReallyCloseable(), Closeable) # True — signature ignored
That is a real trap when used as a guard. The runtime check confirms the attribute exists; it does not confirm the call will succeed. And since Python 3.12, the members of a runtime-checkable protocol are frozen at class creation — monkey-patched attributes do not affect isinstance results.
Prefer
Protocolat boundaries you do not own. Prefer an ABC when you want shared implementation or explicit registration.
Knowledge check
Check your understanding
Answer this question before you continue.
Generics: Reusable Components That Keep Their Types
A container, repository, or pipeline that erases its element type forces callers back to Any and loses checking downstream. Generics preserve the type through the component.
from typing import TypeVar, Generic
T = TypeVar("T")
class Repository(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def add(self, item: T) -> None:
self._items.append(item)
def get(self, index: int) -> T:
return self._items[index]
Repository[User].get(0) returns User, not Any. The checker carries the type through.
TypeVar with bounds and constraints constrains what the component accepts without hard-coding a concrete type. A bound says "any subtype of X." Constraints say "exactly one of these types."
from typing import TypeVar
TNum = TypeVar("TNum", int, float) # constrained
TClose = TypeVar("TClose", bound=HasClose) # bounded
Variance: Why the Repository Above Is Invariant
Variance determines which assignments the checker accepts. The rule is about safe use positions, and getting it wrong produces assignments the checker should reject.
A covariant type parameter is safe for producers — types that only read out values:
T_co = TypeVar("T_co", covariant=True)
class Producer(Generic[T_co]):
def get(self) -> T_co: ...
# Producer[Dog] is assignable to Producer[Animal] when Dog <: Animal
A contravariant type parameter is safe for consumers — types that only write values in:
T_contra = TypeVar("T_contra", contravariant=True)
class Consumer(Generic[T_contra]):
def accept(self, item: T_contra) -> None: ...
# Consumer[Animal] is assignable to Consumer[Dog] when Dog <: Animal
The Repository[T] defined earlier is invariant because it both reads (get) and writes (add). If it were covariant, Repository[Dog] would be assignable to Repository[Animal], and a caller could add an Animal that is not a Dog — a type error the checker must prevent. If it were contravariant, get would return the wrong type. Invariance is the only safe choice for a type that both produces and consumes.
The default is invariant. Reach for covariance or contravariance only when the type parameter appears in exactly one position.
Python 3.12 introduced PEP 695 type parameter syntax:
def first[T](items: list[T]) -> T:
return items[0]
type Point = tuple[float, float]
That is cleaner than the TypeVar-based form, but it requires 3.12+. The older form still works and is what you will see in most existing code.
The failure mode: over-generic signatures that no caller can satisfy, and generics used where a plain concrete type would be clearer. Introduce a type parameter only when the same component is genuinely reused across element types. If it is used with one type, write that type.
Knowledge check
Check your understanding
Answer this question before you continue.
Callable and ParamSpec: Typing Callbacks and Decorators
Callable[[ArgTypes], ReturnType] describes a callback's signature. The bare Callable is nearly untyped and hides arity and argument errors.
from typing import Callable
Handler = Callable[[str, int], bool]
def register(handler: Handler) -> None:
...
The decorator problem is where this gets interesting. A wrapper that takes *args, **kwargs erases the wrapped function's signature unless ParamSpec preserves it:
from typing import Callable, ParamSpec, TypeVar
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
P.args and P.kwargs keep the wrapper transparent to the checker. Call sites still get checked against the original signature. Without ParamSpec, the wrapper becomes Callable[..., Any] and the annotation bought nothing.
Overloads handle callables whose return type depends on input types:
from typing import overload
@overload
def parse(value: str) -> dict: ...
@overload
def parse(value: bytes) -> dict: ...
def parse(value: str | bytes) -> dict:
...
Use overloads sparingly, and prefer generics when they can express the same relationship.
Type the signature when the callback crosses a boundary you do not control. Use
ParamSpecwhenever you write a decorator that wraps a callable.
The failure mode: callbacks typed as Callable[..., Any] pass review and fail at the call site. The annotation is present but carries no information.
Putting the Contracts Together
Here is a minimal component that composes the constructs. It is narrow enough to run and inspect.
from typing import TypedDict, Literal, Protocol, Callable, ParamSpec, TypeVar, NotRequired
class Request(TypedDict):
prompt: str
model: NotRequired[str]
Mode = Literal["sync", "stream"]
class Client(Protocol):
def send(self, payload: Request, mode: Mode) -> str: ...
P = ParamSpec("P")
R = TypeVar("R")
def with_retry(fn: Callable[P, R], attempts: int = 3) -> Callable[P, R]:
if attempts < 1:
raise ValueError("attempts must be >= 1")
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last: Exception | None = None
for _ in range(attempts):
try:
return fn(*args, **kwargs)
except TimeoutError as exc:
last = exc
assert last is not None
raise last
return wrapper
def run(client: Client, payload: Request, mode: Mode) -> str:
return client.send(payload, mode)
Two details in that retry wrapper are deliberate. It catches only TimeoutError — retrying a ValueError or a KeyError just repeats a deterministic failure. And it guards attempts < 1 up front, so the loop always executes at least once and last is guaranteed non-None by the time the raise runs. The assert documents that invariant for the checker; without it, the checker cannot prove last is not None from the loop alone.
Now trace one failure. Rename prompt to query in Request and update only the caller. The checker flags the client.send call because payload no longer matches. Rename it in the Client protocol but not the implementation, and the checker flags the implementation. Change mode from "sync" to "batch", and Literal catches it. Change the callback arity in with_retry, and ParamSpec catches it.
What stays silent: the actual runtime value of payload. If payload comes from json.loads, the checker never sees the missing key. That is the runtime gap, and it is the same gap for every construct here. The boundary still needs a validation step — a schema check, a guard, or a typed constructor — at the point where external data enters.
Where Static Typing Stops Paying
Annotations do not validate external data. A checker-clean boundary can still receive malformed input at runtime. That is not a flaw in the type system; it is the boundary between static analysis and runtime state.
Over-typing internal helpers adds maintenance cost without catching real bugs when inference already covers the case. If a private function takes a list[str] and returns a str, and every caller passes a list[str], the annotation is documentation, not a contract. That is fine — but it is not where the leverage is.
runtime_checkable isinstance checks verify presence, not signatures. Do not treat them as contract enforcement. They are a cheap guard, not a validator.
Version and tooling dependence matters. PEP 695 syntax requires 3.12+. Required and NotRequired require 3.11+. runtime_checkable freezing behavior changed in 3.12. Behavior varies by checker and configuration — mypy, pyright, and pyre do not agree on every edge case.
Spend typing effort where a wrong assumption is silent and expensive. Leave the rest to inference.
The decision rule is the same one from the top: type the seams, not the interior. Public APIs, mixed collections, callable signatures, and boundaries you do not own. Everything else can lean on inference until it proves otherwise.
Next Move
Pick one boundary in your codebase — a dict payload, a callback, or a duck-typed dependency — and replace it with the single construct that fits. Then run a two-part verification.
First, the static check. Run your checker in strict mode against the file and record the inferred type at the boundary. Then break the contract three ways: rename a key in the TypedDict, change a callback's arity, pass a value outside a Literal set. For each break, note whether the checker flags it and where. Exact diagnostic wording is checker-dependent, so record the shape of the error, not the exact text.
Second, the runtime check. Feed malformed JSON through the boundary — a missing required key, a wrong value type — and observe the failure before and after you add a validation step. The static breaks the checker catches are the ones you no longer need to test at runtime. The runtime breaks the checker misses are your validation surface. That gap is the boundary you actually need to guard.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


