PEP: 821
Title: Support for unpacking TypedDicts in Callable type hints
Author: Daniel Sperber <github.blurry@9ox.net>
Sponsor: Jelle Zijlstra <jelle.zijlstra@gmail.com>
Discussions-To: https://discuss.python.org/t/105952
Status: Draft
Type: Standards Track
Topic: Typing
Created: 12-Jan-2026
Python-Version: 3.15
Post-History: `28-Jun-2025 <https://discuss.python.org/t/96975>`__,
              `31-Jan-2026 <https://discuss.python.org/t/105952>`__

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 :ref:`typing specification <typing:callable>` 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` (``Unpack`` for ``**kwargs``) and
  :pep:`728` (``extra_items`` and ``closed``).
* Keeps the feature additive, backwards compatible, and stays mostly a
  typing-specification change only.

Alternatives considered
-----------------------

1. Continue recommending ``Protocol``-based callbacks only.
   This keeps the status quo and avoids changing ``Callable``. However, it is
   syntactically heavier and duplicates concepts already present in
   ``Callable``.

2. Introduce a new ``Callable`` syntax for keywords (e.g., dedicated keyword
   parameter markers inside ``Callable``).
   This would require extending the callable parameter grammar with new
   constructs, creating fresh semantics for optionality, defaults, and extra
   keywords. The design space overlaps with ``TypedDict`` and :pep:`692` and
   risks divergent behavior from existing ``**kwargs`` typing.

3. 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 ``Callable`` and existing typing semantics.

Design trade-offs and decisions
-------------------------------

* Positional parameters: Allowing positional parameters to precede
  ``Unpack[TD]`` retains existing ``Callable`` semantics and mirrors real
  Python functions where positional and keyword-only parameters coexist.
* ``Concatenate``: Combining ``Unpack[TD]`` with ``Concatenate`` would enable
  interspersed keyword-only parameters among ``*args`` and ``**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
<https://typing.python.org/en/latest/spec/callables.html#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 of
  ``Callable``.
* Positional parameters may appear in ``Callable`` before ``Unpack[TD]`` and
  follow existing ``Callable`` semantics.
* Only a ``ParamSpec`` may be substituted by an unpacked ``TypedDict`` within a
  ``Callable``.

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]) -> R``
  or
  ``__call__(self, a: int, b: str = ..., **kwargs: object) -> R``.
* Teachers might want to introduce the concept of ``TypedDict`` with
  ``Callable`` first before introducing ``Protocol``.
* The implicit addition of ``**kwargs: object`` might be surprising to users;
  using ``closed=True`` for 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_items`` from
  :pep:`728`.


Reference Implementation
========================

A prototype exists in mypy:
`python/mypy#16083 <https://github.com/python/mypy/pull/16083>`__.


Rejected Ideas
==============

- Combining ``Unpack[TD]`` with ``Concatenate``. With such support, one could
  write ``Callable[Concatenate[int, Unpack[TD], P], R]`` which in turn would
  allow a keyword-only parameter between ``*args`` and ``**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 ``TypedDict`` unpacks 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 to
  ``Callable[[Unpack[TD]], R]``?
* Is there a necessity to differentiate between normal and ``ReadOnly`` keys?


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 ``Unpack`` with ``**kwargs``
* :pep:`728` - ``extra_items`` in ``TypedDict``
* `mypy PR #16083 - Prototype support <https://github.com/python/mypy/pull/16083>`__
* Revisiting PEP 677 (`discussion thread <https://discuss.python.org/t/pep-677-with-an-easier-to-parse-and-more-expressive-syntax/98408/33>`__)


Copyright
=========

This document is placed in the public domain or under the CC0-1.0-Universal
license, whichever is more permissive.
