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.

Unpack maps a TypedDict onto keyword arguments The RequestOptions TypedDict lists timeout, retries, and verify keys with their types; Unpack projects each one onto a matching keyword parameter that the checker validates per call. RequestOptions timeout: float (Required) retries: int (NotRequired) verify: bool (NotRequired) one TypedDict of options Unpack request(**kwargs) timeout=2.0, retries=3 each keyword type-checked
Unpack projects each TypedDict key onto a keyword parameter the checker validates independently.

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.

Runtime vs static analysis 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 Unpack on a non-TypedDict: **kwargs: Unpack[dict[str, int]] is invalid — mypy reports [misc], pyright reportGeneralTypeIssues. 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 (as NotRequired if 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 typing on 3.11: Unpack for **kwargs needs Python 3.12 in the stdlib typing; on 3.11 import it from typing_extensions or 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.

Back to Variadic Generics with TypeVarTuple