Skip to content

Commit 514dfcc

Browse files
committed
Add 821 draft
1 parent 152a687 commit 514dfcc

File tree

2 files changed

+346
-0
lines changed

2 files changed

+346
-0
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,8 @@ peps/pep-0814.rst @vstinner @corona10
692692
peps/pep-0815.rst @emmatyping
693693
peps/pep-0816.rst @brettcannon
694694
# ...
695+
peps/pep-0821.rst @Daraan
696+
# ...
695697
peps/pep-2026.rst @hugovk
696698
# ...
697699
peps/pep-3000.rst @gvanrossum

peps/pep-0821.rst

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
PEP: 821
2+
Title: Support for unpacking TypedDicts in Callable type hints
3+
Author: Daniel Sperber <github.blurry@9ox.net>
4+
Discussions-To: Pending
5+
Status: Draft
6+
Type: Standards Track
7+
Topic: Typing
8+
Created: 30-Dec-2025
9+
Python-Version: 3.15
10+
Post-History: `28-Jun-2025 <https://discuss.python.org/t/pep-idea-extend-spec-of-callable-to-accept-unpacked-typedicts-to-specify-keyword-only-parameters/96975>`__,
11+
12+
13+
Abstract
14+
========
15+
16+
This PEP proposes allowing ``Unpack[TypedDict]`` as the parameter list inside
17+
``Callable``, enabling concise and type-safe ways to describe keyword-only
18+
callable signatures. Currently, ``Callable`` assumes positional-only
19+
parameters, and typing keyword-only functions requires verbose callback
20+
protocols. With this proposal, the keyword structure defined by a ``TypedDict``
21+
can be reused directly in ``Callable``.
22+
23+
24+
Motivation
25+
==========
26+
27+
The typing specification states:
28+
29+
"Parameters specified using Callable are assumed to be positional-only.
30+
The Callable form provides no way to specify keyword-only parameters,
31+
variadic parameters, or default argument values. For these use cases,
32+
see the section on Callback protocols."
33+
34+
— https://typing.python.org/en/latest/spec/callables.html#callable
35+
36+
This limitation makes it cumbersome to declare callables meant to be invoked
37+
with keyword arguments. The existing solution is to define a Protocol::
38+
39+
class Signature(TypedDict, closed=True):
40+
a: int
41+
42+
class KwCallable(Protocol):
43+
def __call__(self, **kwargs: Unpack[Signature]) -> Any: ...
44+
45+
# or
46+
47+
class KwCallable(Protocol):
48+
def __call__(self, *, a: int) -> Any: ...
49+
50+
This works but is verbose. The new syntax allows the equivalent to be written
51+
more succinctly::
52+
53+
type KwCallable = Callable[[Unpack[Signature]], Any]
54+
55+
56+
Specification
57+
=============
58+
59+
New allowed form
60+
----------------
61+
62+
It becomes valid to write::
63+
64+
Callable[[Unpack[TD]], R]
65+
66+
where ``TD`` is a ``TypedDict``. A shorter form is also allowed::
67+
68+
Callable[Unpack[TD], R]
69+
70+
Additionally, positional parameters may be combined with an unpacked ``TypedDict``::
71+
72+
Callable[[int, str, Unpack[TD]], R]
73+
74+
Semantics
75+
---------
76+
77+
* Each key in the ``TypedDict`` must be accepted as a keyword parameter.
78+
* TypedDict keys cannot be positional-only; they must be valid keyword parameters.
79+
* Positional parameters may appear in ``Callable`` before ``Unpack[TD]`` and follow normal ``Callable`` semantics.
80+
* Required keys must be accepted, but may correspond to parameters with a
81+
default value.
82+
* ``NotRequired`` keys must still be accepted, but may be omitted at call sites.
83+
* Functions with ``**kwargs`` are compatible if the annotation of ``**kwargs``
84+
matches or is a supertype of the ``TypedDict`` values.
85+
* ``extra_items`` from PEP 728 is respected: functions accepting additional
86+
``**kwargs`` are valid if their annotation is compatible with the declared
87+
type.
88+
* If neither ``extra_items`` nor ``closed`` (PEP 728) is specified on the
89+
``TypedDict``, additional keyword arguments are implicitly permitted with
90+
type ``object`` (i.e., compatible with ``**kwargs: object``). Setting
91+
``closed=True`` forbids any additional keyword arguments beyond the keys
92+
declared in the ``TypedDict``. Setting ``extra_items`` to a specific type
93+
requires that any additional keyword arguments match that type.
94+
* Only a single ``TypedDict`` may be unpacked inside a ``Callable``. Support
95+
for multiple unpacks may be considered in the future.
96+
97+
Examples
98+
--------
99+
100+
The following examples illustrate how unpacking a ``TypedDict`` into a
101+
``Callable`` enforces acceptance of specific keyword parameters. A function is
102+
compatible if it can be called with the required keywords (even if they are
103+
also accepted positionally); positional-only parameters for those keys are
104+
rejected.
105+
106+
.. code-block:: python
107+
108+
from typing import TypedDict, Callable, Unpack, Any, NotRequired
109+
110+
class Signature(TypedDict):
111+
a: int
112+
113+
type IntKwCallable = Callable[[Unpack[Signature]], Any]
114+
115+
def normal(a: int): ...
116+
def kw_only(*, a: int): ...
117+
def pos_only(a: int, /): ...
118+
def different(bar: int): ...
119+
120+
f1: IntKwCallable = normal # Accepted
121+
f2: IntKwCallable = kw_only # Accepted
122+
f3: IntKwCallable = pos_only # Rejected
123+
f4: IntKwCallable = different # Rejected
124+
125+
Optional arguments
126+
------------------
127+
128+
Keys marked ``NotRequired`` in the ``TypedDict`` correspond to optional
129+
keyword arguments.
130+
Meaning the callable must accept them, but callers may omit them.
131+
Functions that accept the keyword argument must also provide a default value that is compatible;
132+
functions that omit the parameter entirely are rejected.
133+
134+
.. code-block:: python
135+
136+
class OptSig(TypedDict):
137+
a: NotRequired[int]
138+
139+
type OptCallable = Callable[[Unpack[OptSig]], Any]
140+
141+
def defaulted(a: int = 1): ...
142+
def kw_default(*, a: int = 1): ...
143+
def no_params(): ...
144+
def required(a: int): ...
145+
146+
g1: OptCallable = defaulted # Accepted
147+
g2: OptCallable = kw_default # Accepted
148+
g3: OptCallable = no_params # Rejected
149+
g4: OptCallable = required # Rejected
150+
151+
Additional keyword arguments
152+
----------------------------
153+
154+
Default Behavior (no ``extra_items`` or ``closed``)
155+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
156+
157+
If the ``TypedDict`` does not specify ``extra_items`` or ``closed``, additional
158+
keyword arguments are permitted with type ``object``. This is the default behavior.
159+
160+
.. code-block:: python
161+
162+
# implies extra_items=object
163+
class DefaultTD(TypedDict):
164+
a: int
165+
166+
type DefaultCallable = Callable[[Unpack[DefaultTD]], Any]
167+
168+
def v_any(**kwargs: object): ...
169+
def v_ints(a: int, b: int=2): ...
170+
171+
d1: DefaultCallable = v_any # Accepted (implicit object for extras)
172+
d1(a=1, c="more") # Accepted (extras allowed)
173+
d2: DefaultCallable = v_ints # Rejected (b: int is not a supertype of object)
174+
175+
``closed`` behavior (PEP 728)
176+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
177+
178+
If ``closed=True`` is specified on the ``TypedDict``, no additional keyword
179+
arguments beyond those declared are expected.
180+
181+
.. code-block:: python
182+
183+
class ClosedTD(TypedDict, closed=True):
184+
a: int
185+
186+
type ClosedCallable = Callable[[Unpack[ClosedTD]], Any]
187+
188+
def v_any(**kwargs: object): ...
189+
def v_ints(a: int, b: int=2): ...
190+
191+
c1: ClosedCallable = v_any # Accepted
192+
c1(a=1, c="more") # Rejected (extra c not allowed)
193+
c2: ClosedCallable = v_ints # Accepted
194+
c2(a=1, b=2) # Rejected (extra b not allowed)
195+
196+
Interaction with ``extra_items`` (PEP 728)
197+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
198+
199+
If a ``TypedDict`` specifies the ``extra_items`` parameter, the corresponding ``Callable``
200+
must accept additional keyword arguments of the specified type.
201+
202+
For example:
203+
204+
.. code-block:: python
205+
206+
class ExtraTD(TypedDict, extra_items=str):
207+
a: int
208+
209+
type ExtraCallable = Callable[[Unpack[ExtraTD]], Any]
210+
211+
def accepts_str(**kwargs: str): ...
212+
def accepts_object(**kwargs: object): ...
213+
def accepts_int(**kwargs: int): ...
214+
215+
e1: ExtraCallable = accepts_str # Accepted (matches extra_items type)
216+
e2: ExtraCallable = accepts_object # Accepted (object is a supertype of str)
217+
e3: ExtraCallable = accepts_int # Rejected (int is not a supertype of str)
218+
219+
e1(a=1, b="foo") # Accepted
220+
e1(a=1, b=2) # Rejected (b must be str)
221+
222+
223+
Interaction with ParamSpec:
224+
225+
``ParamSpec`` can be combined with ``Unpack[TypedDict]`` to define a
226+
parameterized callable alias. Substituting ``Unpack[Signature]`` produces the
227+
same effect as writing the callable with an unpacked ``TypedDict`` directly.
228+
229+
.. code-block:: python
230+
231+
from typing import ParamSpec
232+
233+
P = ParamSpec("P")
234+
type CallableP = Callable[P, Any]
235+
236+
# CallableP[Unpack[Signature]] is equivalent to Callable[[Unpack[Signature]], Any]
237+
h: CallableP[Unpack[Signature]] = normal # Accepted
238+
h2: CallableP[Unpack[Signature]] = kw_only # Accepted
239+
h3: CallableP[Unpack[Signature]] = pos_only # Rejected
240+
241+
Combined positional parameters and ``Unpack``:
242+
243+
Positional parameters may precede an unpacked ``TypedDict`` inside ``Callable``.
244+
Functions that accept the required positional arguments and can be called with
245+
the specified keyword(s) are compatible; making the keyword positional-only is
246+
rejected.
247+
248+
.. code-block:: python
249+
250+
from typing import TypedDict, Callable, Unpack, Any
251+
252+
class Signature(TypedDict):
253+
a: int
254+
255+
type IntKwPosCallable = Callable[[int, str, Unpack[Signature]], Any]
256+
257+
def mixed_kwonly(x: int, y: str, *, a: int): ...
258+
def mixed_poskw(x: int, y: str, a: int): ...
259+
def mixed_posonly(x: int, y: str, a: int, /): ...
260+
261+
m1: IntKwPosCallable = mixed_kwonly # Accepted
262+
m2: IntKwPosCallable = mixed_poskw # Accepted
263+
m3: IntKwPosCallable = mixed_posonly # Rejected
264+
265+
Inline TypedDicts (PEP 764):
266+
267+
Inline ``TypedDict`` forms are supported like any other ``TypedDict``, allowing compact definitions when the
268+
structure is used only once.
269+
270+
.. code-block:: python
271+
272+
Callable[[Unpack[TypedDict({"a": int})]], Any]
273+
274+
275+
Backwards Compatibility
276+
=======================
277+
278+
This feature is additive. Existing code is unaffected. Runtime behavior does
279+
not change; this is a typing-only feature.
280+
281+
282+
Reference Implementation
283+
========================
284+
285+
A prototype exists in mypy:
286+
https://github.com/python/mypy/pull/16083
287+
288+
Open Questions
289+
==============
290+
291+
* Should combining ``Unpack[TD]`` with ``Concatenate`` and ``ParamSpec`` be
292+
supported in the future? With such support, one could write
293+
``Callable[Concatenate[int, Unpack[TD], P], R]`` which in turn would allow a keyword-only parameter between ``*args`` and ``**kwargs``, i.e.
294+
``def func(*args: Any, a: int, **kwargs: Any) -> R: ...`` which is currently not allowed per PEP 612.
295+
To keep the initial implementation simple, this PEP does not propose such
296+
support.
297+
* Should multiple ``TypedDict`` unpacks be allowed to form a union, and if so, how to handle
298+
overlapping keys?
299+
300+
301+
How to Teach This
302+
=================
303+
304+
This feature is a shorthand for Protocol-based callbacks. Users should be
305+
taught that with
306+
307+
.. code-block:: python
308+
309+
class Signature(TypedDict):
310+
a: int
311+
b: NotRequired[str]
312+
313+
* ``Callable[[Unpack[Signature]], R]`` is equivalent to defining a Protocol with
314+
``__call__(self, **kwargs: Unpack[Signature]) -> R``
315+
or ``__call__(self, a: int, b: str = ..., **kwargs: object) -> R``.
316+
* The implicit addition of ``**kwargs: object`` might come surprising to users,
317+
using ``closed=True`` for definitions will create the more intuitive equivalence
318+
of ``__call__(self, a: int, b: str = ...) -> R``
319+
320+
Alternatives
321+
============
322+
323+
This (`discussion thread <https://discuss.python.org/t/pep-677-with-an-easier-to-parse-and-more-expressive-syntax/98408/33>`__)
324+
revisits the idea of the rejected :pep:`677`
325+
to introduce alternative and more expressive syntax for callable type hints.
326+
327+
328+
References
329+
==========
330+
331+
* PEP 484 - Type Hints
332+
* PEP 612 - ParamSpec
333+
* PEP 646 - Variadic Generics
334+
* PEP 692 - Using ``Unpack`` with ``**kwargs``
335+
* PEP 728 - ``extra_items`` in TypedDict
336+
* PEP 764 - Inline TypedDict
337+
* mypy PR #16083 - Prototype support
338+
* Revisiting PEP 677 (`discussion thread <https://discuss.python.org/t/pep-677-with-an-easier-to-parse-and-more-expressive-syntax/98408/33>`__)
339+
340+
341+
Copyright
342+
=========
343+
344+
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.

0 commit comments

Comments
 (0)