Unpack for Typed Kwargs (PEP 692)
TL;DR — PEP 692 lets you give **kwargs a precise, per-key type by unpacking a TypedDict: def request(**kwargs: Unpack[RequestOptions]). Each key in the TypedDict becomes a typed keyword argument, Required/NotRequired control which are mandatory, and mypy reports [typeddict-item] or [call-arg] when a caller passes the wrong key or type. Available on Python 3.12+, or 3.11 via typing_extensions.
Before PEP 692, annotating **kwargs: str forced every keyword value to the same type — useless for the common case where a function forwards a bag of differently-typed options. Unpacking a TypedDict solves this: you describe the keyword surface once as a structured type and the checker validates each call site against it. This page, part of Advanced Typing Patterns & Generics, shows the pattern, the required/optional distinction, and how the two major checkers report violations.
The canonical pattern
Define a TypedDict whose keys are your keyword names, then annotate **kwargs with Unpack[...]. On Python 3.12 both Unpack and TypedDict live in typing; on 3.11 import them from typing_extensions.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypedDict, Unpack, NotRequired
class RequestOptions(TypedDict):
timeout: float
retries: NotRequired[int]
verify: NotRequired[bool]
def request(url: str, **kwargs: Unpack[RequestOptions]) -> bytes:
timeout = kwargs["timeout"] # float
retries = kwargs.get("retries", 0) # int
...
return b""
request("https://api.example.com", timeout=2.0, retries=3) # ok
request("https://api.example.com", timeout=2.0, verify=True) # ok
Inside the body, kwargs is a RequestOptions, so kwargs["timeout"] is a float and the missing-key rules of the TypedDict apply. At the call site, each keyword is checked against its declared type independently — exactly what a flat **kwargs: object could never express.
Required vs NotRequired keys
Which keywords a caller must pass is governed by the TypedDict itself. A plain key is required; wrapping it in NotRequired makes it optional. The mirror image, Required, pins a specific key as mandatory inside a total=False TypedDict. See Required and NotRequired keys for the full matrix.
# Python 3.12+, checked with mypy 1.10
from typing import TypedDict, Unpack, Required
class RetryPolicy(TypedDict, total=False):
backoff: float
max_attempts: Required[int] # mandatory despite total=False
def send(**kwargs: Unpack[RetryPolicy]) -> None: ...
send(max_attempts=5) # ok — backoff is optional
send(backoff=1.5) # mypy error: [call-arg] — missing max_attempts
Omitting a required key raises [call-arg] in mypy (pyright: reportCallIssue), the same error you would get from missing a normal positional parameter — the TypedDict’s totality rules become the function’s arity rules.
How analyzers report violations
Two failure modes dominate, and the checkers label them slightly differently. Passing a key that the TypedDict does not declare is an unexpected keyword; passing a declared key with the wrong value type is a type error.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
request("https://api.example.com", timeout="2.0")
# mypy: [typeddict-item] — "2.0" is str, expected float
# pyright: reportArgumentType
request("https://api.example.com", timeout=2.0, follow=True)
# mypy: [call-arg] — unexpected keyword "follow"
# pyright: reportCallIssue
mypy uses [typeddict-item] for a wrong value type and [call-arg] for an unknown or missing keyword; pyright folds both into reportArgumentType / reportCallIssue. Both agree on the outcome — the mismatch is caught — so this pattern is portable across a mixed pyright and mypy setup.
Unpack[RequestOptions] changes nothing at runtime — kwargs is still a plain dict, and Python will happily accept an undeclared keyword or a wrong-typed value. The TypedDict is never instantiated and its Required/NotRequired markers are not enforced. Only the static checker rejects a bad call; if you need runtime validation, add explicit key checks or a library like pydantic.
Forwarding kwargs between functions
The pattern shines when one function forwards options to another. Annotate both ends with the same TypedDict and the checker verifies that the forwarded bag still satisfies the callee.
# Python 3.12+, checked with mypy 1.10
from typing import TypedDict, Unpack, NotRequired
class ConnectOptions(TypedDict):
host: str
port: NotRequired[int]
def open_socket(**opts: Unpack[ConnectOptions]) -> None: ...
def connect(retries: int, **opts: Unpack[ConnectOptions]) -> None:
for _ in range(retries):
open_socket(**opts) # ok — opts already matches ConnectOptions
Because opts is typed as ConnectOptions, splatting it into open_socket(**opts) type-checks with no cast — the two signatures share one source of truth.
Common mistakes
- Using
Unpackon a non-TypedDict:**kwargs: Unpack[dict[str, int]]is invalid — mypy reports[misc], pyrightreportGeneralTypeIssues. Only a TypedDict may be unpacked into**kwargs. - Passing an unknown keyword: any keyword absent from the TypedDict triggers
[call-arg]/reportCallIssue. Add the key (asNotRequiredif optional) rather than falling back to**kwargs: object. - Wrong value type for a declared key: yields
[typeddict-item]in mypy. The value must match the key’s annotated type, not merely be truthy. - Importing from
typingon 3.11:Unpackfor**kwargsneeds Python 3.12 in the stdlibtyping; on 3.11 import it fromtyping_extensionsor the checker will not apply PEP 692 semantics.
FAQ
Can I combine Unpack kwargs with regular positional parameters?
Yes. Positional and positional-or-keyword parameters come first, then **kwargs: Unpack[SomeTypedDict] last — for example def connect(retries: int, **opts: Unpack[ConnectOptions]). The TypedDict only governs the keyword surface.
Does PEP 692 replace TypedDict for return values or storage?
No. PEP 692 is purely about the call surface of **kwargs. Use a normal TypedDict value where you actually build and pass a dict; Unpack is the bridge that turns that structure into individually-checked keyword arguments.