PEP 821 – Support for unpacking TypedDicts in Callable type hints
- Author:
- Daniel Sperber <github.blurry at 9ox.net>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Pending
- Status:
- Draft
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 12-Jan-2026
- Python-Version:
- 3.15
- Post-History:
- 28-Jun-2025
Abstract
This PEP proposes allowing Unpack[TypedDict] in the parameter list inside
Callable, enabling concise and type-safe ways to describe keyword-only
callable signatures. Currently, Callable assumes positional-only
parameters, and typing keyword-only functions requires verbose callback
protocols. With this proposal, the keyword structure defined by a TypedDict
can be reused directly in Callable.
Motivation
The typing specification states:
“Parameters specified using Callable are assumed to be positional-only. The Callable form provides no way to specify keyword-only parameters, or default argument values. For these use cases, see the section on Callback protocols.”
This limitation makes it cumbersome to declare callables meant to be invoked
with keyword arguments. The existing solution is to define a Protocol:
class KeywordTD(TypedDict, closed=True):
a: int
class KwCallable(Protocol):
def __call__(self, **kwargs: Unpack[KeywordTD]) -> Any: ...
# or
class KwCallable(Protocol):
def __call__(self, *, a: int) -> Any: ...
This works but is verbose. The new syntax allows the equivalent to be written more succinctly:
type KwCallable = Callable[[Unpack[KeywordTD]], Any]
Rationale
Design goals
The primary goal is to make the common pattern of “callbacks that are intended
to be called with specific keyword arguments” straightforward to express with
Callable. Today, such callbacks must be written as a Protocol with a
__call__ that uses **kwargs: Unpack[...] or includes each keyword
parameter explicitly. This approach is verbose and inconsistent
with how positional variadics are supported: per PEP 646, *args can be
expressed as *tuple[int, ...] inside Callable.
Allowing Unpack[TypedDict] inside Callable achieves the following:
- Preserves the familiar
Callable[[...], R]shape while enabling keyword-only parameter descriptions. - Provides a concise shorthand equivalent to Protocol-based callbacks with
__call__(self, **kwargs: Unpack[TD]) -> R. - Provides the intuitive analogue to positional variadics.
- Reuses existing building blocks and keeps semantics predictable. It aligns
with existing semantics from PEP 692 (
Unpackfor**kwargs) and PEP 728 (extra_itemsandclosed). - Keeps the feature additive, backwards compatible, and stays mostly a typing-specification change only.
Alternatives considered
- Continue recommending
Protocol-based callbacks only. This keeps the status quo and avoids changingCallable. However, it is syntactically heavier and duplicates concepts already present inCallable. - Introduce a new
Callablesyntax for keywords (e.g., dedicated keyword parameter markers insideCallable). This would require extending the callable parameter grammar with new constructs, creating fresh semantics for optionality, defaults, and extra keywords. The design space overlaps withTypedDictand PEP 692 and risks divergent behavior from existing**kwargstyping. - Adopt callback literal syntax (cf. PEP 677) to express rich signatures
inline. Literal syntax can improve readability but introduces a larger,
orthogonal change. This PEP seeks a focused, minimal extension that works
within
Callableand existing typing semantics.
Design trade-offs and decisions
- Positional parameters: Allowing positional parameters to precede
Unpack[TD]retains existingCallablesemantics and mirrors real Python functions where positional and keyword-only parameters coexist. Concatenate: CombiningUnpack[TD]withConcatenatewould enable interspersed keyword-only parameters among*argsand**kwargs. This increases complexity and is not proposed here.
Specification
New allowed form
It becomes valid to write:
Callable[[Unpack[TD]], R]
where TD is a TypedDict. A shorter form is also allowed:
Callable[Unpack[TD], R]
Additionally, positional parameters may be combined with an unpacked
TypedDict:
Callable[[int, str, Unpack[TD]], R]
Semantics
For type-checking purposes, Callable[[Unpack[TD]], R] behaves as if it were
specified via a callback protocol whose __call__ method has
**kwargs: Unpack[TD].
The semantics of Unpack itself are exactly those described in the typing
specification’s Unpack for keyword arguments
section and PEP 692, together with PEP 728 for extra_items and
closed.
This PEP only adds the following Callable-specific rules:
Unpack[TD]may appear inside the parameter list ofCallable.- Positional parameters may appear in
CallablebeforeUnpack[TD]and follow existingCallablesemantics. - Only a
ParamSpecmay be substituted by an unpackedTypedDictwithin aCallable.
Examples
The following examples illustrate how unpacking a TypedDict into a
Callable enforces acceptance of specific keyword parameters. A function is
compatible if it can be called with the required keywords (even if they are
also accepted positionally); positional-only parameters for those keys are
rejected:
from typing import TypedDict, Callable, Unpack, Any, NotRequired
class KeywordTD(TypedDict):
a: int
type IntKwCallable = Callable[[Unpack[KeywordTD]], Any]
def normal(a: int): ...
def kw_only(*, a: int): ...
def pos_only(a: int, /): ...
def different(bar: int): ...
f1: IntKwCallable = normal # Accepted
f2: IntKwCallable = kw_only # Accepted
f3: IntKwCallable = pos_only # Rejected
f4: IntKwCallable = different # Rejected
Optional arguments
Keys marked NotRequired in the TypedDict correspond to optional
keyword arguments.
This means that the callable must accept them, but callers may omit them.
Functions that accept the keyword argument must also provide a default value
that is compatible; functions that omit the parameter entirely are rejected:
class OptionalKws(TypedDict):
a: NotRequired[int]
type OptCallable = Callable[[Unpack[OptionalKws]], Any]
def defaulted(a: int = 1): ...
def kw_default(*, a: int = 1): ...
def no_params(): ...
def required(a: int): ...
g1: OptCallable = defaulted # Accepted
g2: OptCallable = kw_default # Accepted
g3: OptCallable = no_params # Rejected
g4: OptCallable = required # Rejected
Additional keyword arguments
Default Behavior (no extra_items or closed)
If the TypedDict does not specify extra_items or closed, additional
keyword arguments are permitted with type object.
This is the default behavior:
# implies extra_items=object
class DefaultTD(TypedDict):
a: int
type DefaultCallable = Callable[[Unpack[DefaultTD]], Any]
def v_any(**kwargs: object): ...
def v_ints(a: int, b: int=2): ...
d1: DefaultCallable = v_any # Accepted (implicit object for extras)
d1(a=1, c="more") # Accepted (extras allowed)
d2: DefaultCallable = v_ints # Rejected (b: int is not a supertype of object)
closed behavior (PEP 728)
If closed=True is specified on the TypedDict, no additional keyword
arguments beyond those declared are expected:
class ClosedTD(TypedDict, closed=True):
a: int
type ClosedCallable = Callable[[Unpack[ClosedTD]], Any]
def v_any(**kwargs: object): ...
def v_ints(a: int, b: int=2): ...
c1: ClosedCallable = v_any # Accepted
c1(a=1, c="more") # Rejected (extra c not allowed)
c2: ClosedCallable = v_ints # Accepted
c2(a=1, b=2) # Rejected (extra b not allowed)
Interaction with extra_items (PEP 728)
If a TypedDict specifies the extra_items parameter (with the exception
of extra_items=Never), the corresponding Callable
must accept additional keyword arguments of the specified type.
For example:
class ExtraTD(TypedDict, extra_items=str):
a: int
type ExtraCallable = Callable[[Unpack[ExtraTD]], Any]
def accepts_str(**kwargs: str): ...
def accepts_object(**kwargs: object): ...
def accepts_int(**kwargs: int): ...
e1: ExtraCallable = accepts_str # Accepted (matches extra_items type)
e2: ExtraCallable = accepts_object # Accepted (object is a supertype of str)
e3: ExtraCallable = accepts_int # Rejected (int is not a supertype of str)
e1(a=1, b="foo") # Accepted
e1(a=1, b=2) # Rejected (b must be str)
Interaction with ParamSpec and Concatenate
A ParamSpec can be substituted by Unpack[KeywordTD] to define a
parameterized callable alias. Substituting Unpack[KeywordTD] produces the
same effect as writing the callable with an unpacked TypedDict directly.
Using a TypedDict within Concatenate is not allowed.
type CallableP[**P] = Callable[P, Any]
h: CallableP[Unpack[KeywordTD]] = normal # Accepted
h2: CallableP[Unpack[KeywordTD]] = kw_only # Accepted
h3: CallableP[Unpack[KeywordTD]] = pos_only # Rejected
The current implementation needs to be updated to allow subscripting with a
generic Unpack[TypedDict] without extra brackets;
see Backwards Compatibility.
Combined positional parameters and Unpack
Positional parameters may precede an unpacked TypedDict inside Callable.
Functions that accept the required positional arguments and can be called with
the specified keyword(s) are compatible; making the keyword positional-only is
rejected:
from typing import TypedDict, Callable, Unpack, Any
class KeywordTD(TypedDict):
a: int
type IntKwPosCallable = Callable[[int, str, Unpack[KeywordTD]], Any]
def mixed_kwonly(x: int, y: str, *, a: int): ...
def mixed_poskw(x: int, y: str, a: int): ...
def mixed_posonly(x: int, y: str, a: int, /): ...
m1: IntKwPosCallable = mixed_kwonly # Accepted
m2: IntKwPosCallable = mixed_poskw # Accepted
m3: IntKwPosCallable = mixed_posonly # Rejected
Backwards Compatibility
This feature is mostly an additive typing-only feature. It does not affect
existing code.
Subscripting a ParamSpec with a generic Unpack of a TypedDict is
only backwards compatible when placed inside extra brackets; a TypeAliasType
is not affected by this:
from typing import TypedDict, ParamSpec, Callable, Unpack
from typing import TypeAliasType
class Config[T](TypedDict):
setting: T
type CallP1[**P] = Callable[P, None]
CallP1_SubbedB = CallP1[Unpack[Config[int]]] # OK
P = ParamSpec("P")
CallP2 = Callable[P, None]
CallP2_SubbedA = CallP2[[Unpack[Config[int]]]] # OK
CallP2_SubbedB = CallP2[Unpack[Config[int]]] # currently TypeError
How to Teach This
This feature is a shorthand for Protocol-based callbacks. Users should be taught that with
class KeywordTD(TypedDict):
a: int
b: NotRequired[str]
Callable[[Unpack[KeywordTD]], R]is equivalent to defining a Protocol with__call__(self, **kwargs: Unpack[KeywordTD]) -> Ror__call__(self, a: int, b: str = ..., **kwargs: object) -> R.- Teachers might want to introduce the concept of
TypedDictwithCallablefirst before introducingProtocol. - The implicit addition of
**kwargs: objectmight be surprising to users; usingclosed=Truefor definitions will create the more intuitive equivalence of__call__(self, a: int, b: str = ...) -> R - Users should be made aware of the interaction with
extra_itemsfrom PEP 728.
Reference Implementation
A prototype exists in mypy: python/mypy#16083.
Rejected Ideas
- Combining
Unpack[TD]withConcatenate. With such support, one could writeCallable[Concatenate[int, Unpack[TD], P], R]which in turn would allow a keyword-only parameter between*argsand**kwargs, i.e.def func(*args: Any, a: int, **kwargs: Any) -> R: ...which is currently not allowed per PEP 612. To keep the initial implementation simple, this PEP does not propose such support.
Open Questions
- Should multiple
TypedDictunpacks be allowed to form a union, and if so, how to handle overlapping keys of non-identical types? Which restrictions should apply in such a case? Should the order matter? - Should we allow the shorter form
Callable[Unpack[TD], R]in addition toCallable[[Unpack[TD]], R]? - Is there a necessity to differentiate between normal and
ReadOnlykeys?
Acknowledgements
Thanks to Jelle Zijlstra for sponsoring this PEP and his valuable review feedback.
Hugo van Kemenade, for helpful feedback on the draft and PR of this PEP.
Eric Traut, for feedback on the initial idea and discussions.
References
- PEP 692 - Using
Unpackwith**kwargs - PEP 728 -
extra_itemsinTypedDict - mypy PR #16083 - Prototype support
- Revisiting PEP 677 (discussion thread)
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
Source: https://github.com/python/peps/blob/main/peps/pep-0821.rst
Last modified: 2026-01-31 04:34:14 GMT