Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 821 – Support for unpacking TypedDicts in Callable type hints

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

Table of Contents

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 (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 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.

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


Source: https://github.com/python/peps/blob/main/peps/pep-0821.rst

Last modified: 2026-01-31 04:34:14 GMT